diff --git a/.commitlintrc.json b/.commitlintrc.json index c3a5e516e..60b0b0b68 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -20,7 +20,8 @@ "release", "repo", "styling", - "observability" + "observability", + "analytics" ] ] } diff --git a/apps/api/CHANGELOG.md b/apps/api/CHANGELOG.md index e915c438d..ab0362cb5 100644 --- a/apps/api/CHANGELOG.md +++ b/apps/api/CHANGELOG.md @@ -1,5 +1,131 @@ +## [2.35.2](https://github.com/akash-network/console/compare/console-api/v2.35.2-beta.0...console-api/v2.35.2) (2024-12-02) + +## [2.35.2-beta.0](https://github.com/akash-network/console/compare/console-api/v2.35.1...console-api/v2.35.2-beta.0) (2024-11-28) + + +### Bug Fixes + +* **observability:** ensure pino-pretty works in built app ([7f6f9ca](https://github.com/akash-network/console/commit/7f6f9ca7ca4e1ff4bc3b85735270f61cc8120242)), closes [#474](https://github.com/akash-network/console/issues/474) + +## [2.35.1](https://github.com/akash-network/console/compare/console-api/v2.35.1-beta.1...console-api/v2.35.1) (2024-11-28) + +## [2.35.1-beta.1](https://github.com/akash-network/console/compare/console-api/v2.35.1-beta.0...console-api/v2.35.1-beta.1) (2024-11-28) + + +### Bug Fixes + +* **deployment:** provider deployments query fix ([4278bbd](https://github.com/akash-network/console/commit/4278bbd718d56a71d49baefd73d1b2d35e427aff)), closes [#504](https://github.com/akash-network/console/issues/504) + +## [2.35.1-beta.0](https://github.com/akash-network/console/compare/console-api/v2.35.0...console-api/v2.35.1-beta.0) (2024-11-28) + + +### Bug Fixes + +* **deployment:** fix console arg to object mapping ([6126106](https://github.com/akash-network/console/commit/6126106a800d7006b726ff98190e09368cc0c130)), closes [#503](https://github.com/akash-network/console/issues/503) + +## [2.35.0](https://github.com/akash-network/console/compare/console-api/v2.35.0-beta.0...console-api/v2.35.0) (2024-11-27) + +## [2.35.0-beta.0](https://github.com/akash-network/console/compare/console-api/v2.34.0...console-api/v2.35.0-beta.0) (2024-11-27) + + +### Features + +* **deployment:** clean up trial deployments for a provider ([41018af](https://github.com/akash-network/console/commit/41018afc0593621c4627369b9f114f849e249e44)), closes [#502](https://github.com/akash-network/console/issues/502) + +## [2.34.0](https://github.com/akash-network/console/compare/console-api/v2.34.0-beta.1...console-api/v2.34.0) (2024-11-26) + +## [2.34.0-beta.1](https://github.com/akash-network/console/compare/console-api/v2.34.0-beta.0...console-api/v2.34.0-beta.1) (2024-11-26) + + +### Features + +* **deployment:** implement ato top up setting ([1301314](https://github.com/akash-network/console/commit/130131485a68f699587415f96283e0dc83072502)), closes [#412](https://github.com/akash-network/console/issues/412) + +## [2.34.0-beta.0](https://github.com/akash-network/console/compare/console-api/v2.33.1...console-api/v2.34.0-beta.0) (2024-11-23) + + +### Features + +* **provider:** new provider trial endpoint ([2712e38](https://github.com/akash-network/console/commit/2712e380b8f5af0930abbdf9347a1dee3eb75f8a)), closes [#488](https://github.com/akash-network/console/issues/488) + +## [2.33.1](https://github.com/akash-network/console/compare/console-api/v2.33.1-beta.0...console-api/v2.33.1) (2024-11-23) + +## [2.33.1-beta.0](https://github.com/akash-network/console/compare/console-api/v2.33.0...console-api/v2.33.1-beta.0) (2024-11-21) + + +### Bug Fixes + +* **deployment:** latest processed height deployment stale ([8d8384f](https://github.com/akash-network/console/commit/8d8384f519ae958e324a81fbf5a2ae00383bddc3)), closes [#491](https://github.com/akash-network/console/issues/491) + +## [2.33.0](https://github.com/akash-network/console/compare/console-api/v2.33.0-beta.1...console-api/v2.33.0) (2024-11-21) + +## [2.33.0-beta.1](https://github.com/akash-network/console/compare/console-api/v2.33.0-beta.0...console-api/v2.33.0-beta.1) (2024-11-21) + + +### Bug Fixes + +* **billing:** only resolve with active stripe prices ([fa32f37](https://github.com/akash-network/console/commit/fa32f37bbdce46a69cbd2f1d2f242de66004f7fb)) + +## [2.33.0-beta.0](https://github.com/akash-network/console/compare/console-api/v2.32.0...console-api/v2.33.0-beta.0) (2024-11-21) + + +### Features + +* **billing:** enable checkout options with promo codes ([0cb439d](https://github.com/akash-network/console/commit/0cb439dcf4ca21974d7dacd784570cd032ee9f7b)) + +## [2.32.0](https://github.com/akash-network/console/compare/console-api/v2.32.0-beta.1...console-api/v2.32.0) (2024-11-19) + +## [2.32.0-beta.1](https://github.com/akash-network/console/compare/console-api/v2.32.0-beta.0...console-api/v2.32.0-beta.1) (2024-11-19) + + +### Features + +* **billing:** enable promo codes on checkout via env var ([18f24f6](https://github.com/akash-network/console/commit/18f24f61d52d19364588545323ab621dcdd3b440)) + +## [2.32.0-beta.0](https://github.com/akash-network/console/compare/console-api/v2.31.0...console-api/v2.32.0-beta.0) (2024-11-19) + + +### Features + +* **billing:** enable promo codes on checkout ([de11211](https://github.com/akash-network/console/commit/de112115d61c189849a9cffa83c620487be38093)) + +## [2.31.0](https://github.com/akash-network/console/compare/console-api/v2.31.0-beta.1...console-api/v2.31.0) (2024-11-19) + +## [2.31.0-beta.1](https://github.com/akash-network/console/compare/console-api/v2.31.0-beta.0...console-api/v2.31.0-beta.1) (2024-11-19) + + +### Features + +* **analytics:** add user analytics and refactor analytic related logic ([552cd82](https://github.com/akash-network/console/commit/552cd8244634bf1de49875ce0d9b7490466ae5b0)) + +## [2.31.0-beta.0](https://github.com/akash-network/console/compare/console-api/v2.30.1...console-api/v2.31.0-beta.0) (2024-11-18) + + +### Features + +* **deployment:** implement concurrency option for stale deployments cleaner ([54cae5d](https://github.com/akash-network/console/commit/54cae5d0f3c37dd6fe6623bcc249379f99cad247)) +* **user:** implement dry run and summary logging for stale anonymous users cleaner ([61752e9](https://github.com/akash-network/console/commit/61752e90fecc559eade828c721fa54839d8aef49)), closes [#464](https://github.com/akash-network/console/issues/464) + +## [2.30.1](https://github.com/akash-network/console/compare/console-api/v2.30.1-beta.0...console-api/v2.30.1) (2024-11-15) + +## [2.30.1-beta.0](https://github.com/akash-network/console/compare/console-api/v2.30.0...console-api/v2.30.1-beta.0) (2024-11-15) + + +### Bug Fixes + +* **observability:** bump logger version ([b258c63](https://github.com/akash-network/console/commit/b258c6389d22c0bf57e9c702b51a1280faf74eb7)) + +## [2.30.0](https://github.com/akash-network/console/compare/console-api/v2.30.0-beta.0...console-api/v2.30.0) (2024-11-15) + +## [2.30.0-beta.0](https://github.com/akash-network/console/compare/console-api/v2.29.1...console-api/v2.30.0-beta.0) (2024-11-15) + + +### Features + +* **user:** implement stale anonymous users cleanup cli command ([a936f44](https://github.com/akash-network/console/commit/a936f44c6e532efc1f559986c352594237ce3691)), closes [#464](https://github.com/akash-network/console/issues/464) + ## [2.29.1](https://github.com/akash-network/console/compare/console-api/v2.29.1-beta.0...console-api/v2.29.1) (2024-11-13) ## [2.29.1-beta.0](https://github.com/akash-network/console/compare/console-api/v2.29.0...console-api/v2.29.1-beta.0) (2024-11-13) 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/env/.env b/apps/api/env/.env index dd1435002..6b2d3843c 100644 --- a/apps/api/env/.env +++ b/apps/api/env/.env @@ -1,4 +1,4 @@ -CORS_WEBSITE_URLS=https://stats.akash.network,https://console.akash.network,https://akash.network,https://akash.hooman.digital,http://localhost:3000,http://localhost:3001,https://akashconsole.vercel.app,https://console-beta.akash.network +CORS_WEBSITE_URLS=https://stats.akash.network,https://console.akash.network,https://akash.network,https://akash.hooman.digital,http://localhost:3000,http://localhost:3001,https://akashconsole.vercel.app,https://console-beta.akash.network,https://provider-console-beta.akash.network,https://provider-console.akash.network WEBSITE_URL=https://console-beta.akash.network TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT=10000000 DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT=10000000 @@ -7,3 +7,4 @@ TRIAL_FEES_ALLOWANCE_AMOUNT=1000000 FEE_ALLOWANCE_REFILL_AMOUNT=1000000 FEE_ALLOWANCE_REFILL_THRESHOLD=100000 LOG_LEVEL=debug +STRIPE_PRODUCT_ID=prod_QjTVQg5WkIe39Q \ No newline at end of file diff --git a/apps/api/env/.env.functional.test b/apps/api/env/.env.functional.test index f49bbae97..a68f879b7 100644 --- a/apps/api/env/.env.functional.test +++ b/apps/api/env/.env.functional.test @@ -17,7 +17,7 @@ LOG_LEVEL=debug BILLING_ENABLED=true ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET STRIPE_SECRET_KEY=STRIPE_SECRET_KEY -STRIPE_PRICE_ID=STRIPE_PRICE_ID +STRIPE_PRODUCT_ID=STRIPE_PRODUCT_ID STRIPE_WEBHOOK_SECRET=STRIPE_WEBHOOK_SECRET ALLOWED_CHECKOUT_REFERRERS=["http://localhost:3000"] STRIPE_CHECKOUT_REDIRECT_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/api/env/.env.sample b/apps/api/env/.env.sample index ea7075e3a..1715742cf 100644 --- a/apps/api/env/.env.sample +++ b/apps/api/env/.env.sample @@ -8,7 +8,7 @@ POSTGRES_DB_URI= SECRET_TOKEN= SENTRY_DSN= STRIPE_SECRET_KEY= -STRIPE_PRICE_ID= +STRIPE_PRODUCT_ID= USER_DATABASE_CS= # Configuration diff --git a/apps/api/env/.env.staging b/apps/api/env/.env.staging index 6c873385d..8c03abb98 100644 --- a/apps/api/env/.env.staging +++ b/apps/api/env/.env.staging @@ -6,5 +6,4 @@ AUTH0_ISSUER=https://dev-5aprb0lr.us.auth0.com/ SENTRY_TRACES_RATE=1.0 SENTRY_ENABLED=true BILLING_ENABLED=true -STRIPE_PRICE_ID=price_1Ps0Y0Csz6Fy2xVWVy8GMTA9 STRIPE_CHECKOUT_REDIRECT_URL=https://console-beta.akash.network \ No newline at end of file diff --git a/apps/api/env/.env.unit.test b/apps/api/env/.env.unit.test index 2f22dae59..1abebe90f 100644 --- a/apps/api/env/.env.unit.test +++ b/apps/api/env/.env.unit.test @@ -17,7 +17,7 @@ LOG_LEVEL=debug BILLING_ENABLED=true ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET STRIPE_SECRET_KEY=STRIPE_SECRET_KEY -STRIPE_PRICE_ID=STRIPE_PRICE_ID +STRIPE_PRODUCT_ID=STRIPE_PRODUCT_ID STRIPE_WEBHOOK_SECRET=STRIPE_WEBHOOK_SECRET ALLOWED_CHECKOUT_REFERRERS=["http://localhost:3000"] STRIPE_CHECKOUT_REDIRECT_URL=http://localhost:3000 diff --git a/apps/api/mvm.lock b/apps/api/mvm.lock index 7e5d99ebf..eeba268dd 100644 --- a/apps/api/mvm.lock +++ b/apps/api/mvm.lock @@ -3,6 +3,6 @@ "@akashnetwork/database": "1.0.0", "@akashnetwork/env-loader": "1.0.1", "@akashnetwork/http-sdk": "1.0.8", - "@akashnetwork/logging": "2.0.0" + "@akashnetwork/logging": "2.0.2" } } diff --git a/apps/api/package.json b/apps/api/package.json index d51bbdccd..75014f3f2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@akashnetwork/console-api", - "version": "2.29.1", + "version": "2.35.2", "description": "Api providing data to the deploy tool", "repository": { "type": "git", @@ -21,6 +21,7 @@ "migrate": "node-pg-migrate", "migration:gen": "drizzle-kit generate", "prod": "doppler run -- node dist/server.js", + "release": "release-it", "start": "webpack --config webpack.dev.js --watch", "test": "jest --selectProjects unit functional", "test:cov": "jest --selectProjects unit functional --coverage", @@ -30,8 +31,7 @@ "test:unit": "jest --selectProjects unit", "test:unit:cov": "jest --selectProjects unit --coverage", "test:unit:watch": "jest --selectProjects unit --watch", - "test:watch": "jest --selectProjects unit functional --watch", - "release": "release-it" + "test:watch": "jest --selectProjects unit functional --watch" }, "dependencies": { "@akashnetwork/akash-api": "^1.3.0", @@ -54,12 +54,11 @@ "@hono/swagger-ui": "0.2.1", "@hono/zod-openapi": "0.9.5", "@octokit/rest": "^18.12.0", - "@opentelemetry/instrumentation": "^0.54.0", - "@opentelemetry/instrumentation-http": "^0.54.0", - "@opentelemetry/sdk-node": "^0.54.0", + "@opentelemetry/instrumentation": "^0.54.2", + "@opentelemetry/instrumentation-http": "^0.54.2", + "@opentelemetry/sdk-node": "^0.54.2", "@sentry/node": "^7.55.2", "@supercharge/promise-pool": "^3.2.0", - "@types/jsonwebtoken": "^9.0.6", "@ucast/core": "^1.10.2", "async-sema": "^3.1.1", "axios": "^1.7.2", @@ -104,6 +103,7 @@ "@types/http-assert": "^1.5.5", "@types/http-errors": "^2.0.4", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.0", "@types/memory-cache": "^0.2.2", "@types/node": "20.14.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4f33addd8..054fb0117 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { Context, Hono, Next } from "hono"; import { cors } from "hono/cors"; import { container } from "tsyringe"; +import { AuthInterceptor } from "@src/auth/services/auth.interceptor"; import { config } from "@src/core/config"; import { getSentry, sentryOptions } from "@src/core/providers/sentry.provider"; import { HonoErrorHandlerService } from "@src/core/services/hono-error-handler/hono-error-handler.service"; @@ -23,7 +24,9 @@ import { userRouter } from "./routers/userRouter"; import { web3IndexRouter } from "./routers/web3indexRouter"; import { env } from "./utils/env"; import { bytesToHumanReadableSize } from "./utils/files"; +import { checkoutRouter, getWalletListRouter, signAndBroadcastTxRouter, startTrialRouter, stripePricesRouter, stripeWebhook } from "./billing"; import { Scheduler } from "./scheduler"; +import { createAnonymousUserRouter, getAnonymousUserRouter } from "./user"; const appHono = new Hono(); appHono.use( @@ -33,7 +36,7 @@ appHono.use( }) ); -const { PORT = 3080, BILLING_ENABLED } = process.env; +const { PORT = 3080 } = process.env; const scheduler = new Scheduler({ healthchecksEnabled: env.HEALTHCHECKS_ENABLED === "true", @@ -45,6 +48,7 @@ const scheduler = new Scheduler({ appHono.use(container.resolve(HttpLoggerService).intercept()); appHono.use(container.resolve(RequestContextInterceptor).intercept()); +appHono.use(container.resolve(AuthInterceptor).intercept()); appHono.use("*", async (c: Context, next: Next) => { const { sentry } = await import("@hono/sentry"); return sentry({ @@ -63,23 +67,15 @@ appHono.route("/web3-index", web3IndexRouter); appHono.route("/dashboard", dashboardRouter); appHono.route("/internal", internalRouter); -// TODO: remove condition once billing is in prod -if (BILLING_ENABLED === "true") { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { AuthInterceptor } = require("./auth/services/auth.interceptor"); - appHono.use(container.resolve(AuthInterceptor).intercept()); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { startTrialRouter, getWalletListRouter, signAndBroadcastTxRouter, checkoutRouter, stripeWebhook } = require("./billing"); - appHono.route("/", startTrialRouter); - appHono.route("/", getWalletListRouter); - appHono.route("/", signAndBroadcastTxRouter); - appHono.route("/", checkoutRouter); - appHono.route("/", stripeWebhook); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { createAnonymousUserRouter, getAnonymousUserRouter } = require("./user"); - appHono.route("/", createAnonymousUserRouter); - appHono.route("/", getAnonymousUserRouter); -} +appHono.route("/", startTrialRouter); +appHono.route("/", getWalletListRouter); +appHono.route("/", signAndBroadcastTxRouter); +appHono.route("/", checkoutRouter); +appHono.route("/", stripeWebhook); +appHono.route("/", stripePricesRouter); + +appHono.route("/", createAnonymousUserRouter); +appHono.route("/", getAnonymousUserRouter); appHono.get("/status", c => { const version = packageJson.version; diff --git a/apps/api/src/auth/services/ability/ability.service.ts b/apps/api/src/auth/services/ability/ability.service.ts index 321a27357..d0527cdc7 100644 --- a/apps/api/src/auth/services/ability/ability.service.ts +++ b/apps/api/src/auth/services/ability/ability.service.ts @@ -12,7 +12,8 @@ export class AbilityService { private readonly RULES: Record = { REGULAR_USER: [ { action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, - { action: "read", subject: "User", conditions: { id: "${user.id}" } } + { action: "read", subject: "User", conditions: { id: "${user.id}" } }, + { action: "read", subject: "StripePrice" } ], REGULAR_ANONYMOUS_USER: [ { action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } }, diff --git a/apps/api/src/auth/services/auth.interceptor.ts b/apps/api/src/auth/services/auth.interceptor.ts index 84e22dd3d..26623facd 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_USER" : "REGULAR_ANONYMOUS_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..e5e9efcb4 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(), @@ -17,9 +17,10 @@ const envSchema = z.object({ ALLOWANCE_REFILL_BATCH_SIZE: z.number({ coerce: true }).default(10), MASTER_WALLET_BATCHING_INTERVAL_MS: z.number().optional().default(1000), STRIPE_SECRET_KEY: z.string(), - STRIPE_PRICE_ID: z.string(), + STRIPE_PRODUCT_ID: z.string(), STRIPE_WEBHOOK_SECRET: z.string(), - STRIPE_CHECKOUT_REDIRECT_URL: z.string() + STRIPE_CHECKOUT_REDIRECT_URL: z.string(), + STRIPE_ENABLE_COUPONS: z.enum(["true", "false"]).default("false") }); export const envConfig = envSchema.parse(process.env); diff --git a/apps/api/src/billing/controllers/checkout/checkout.controller.ts b/apps/api/src/billing/controllers/checkout/checkout.controller.ts index 596c10ed5..21101d743 100644 --- a/apps/api/src/billing/controllers/checkout/checkout.controller.ts +++ b/apps/api/src/billing/controllers/checkout/checkout.controller.ts @@ -23,9 +23,20 @@ export class CheckoutController { return c.redirect(`${redirectUrl}?unauthorized=true`); } - const session = await this.checkoutService.checkoutFor(currentUser, redirectUrl); + try { + const session = await this.checkoutService.checkoutFor({ + user: currentUser, + redirectUrl, + amount: c.req.query("amount") + }); - return c.redirect(session.url); + return c.redirect(session.url); + } catch (error) { + if (error.message === "Price invalid") { + return c.redirect(`${redirectUrl}?invalid-price=true`); + } + return c.redirect(`${redirectUrl}?unknown-error=true`); + } } async webhook(signature: string, input: string) { diff --git a/apps/api/src/billing/controllers/stripe/stripe.controller.ts b/apps/api/src/billing/controllers/stripe/stripe.controller.ts new file mode 100644 index 000000000..92af06796 --- /dev/null +++ b/apps/api/src/billing/controllers/stripe/stripe.controller.ts @@ -0,0 +1,15 @@ +import { singleton } from "tsyringe"; + +import { Protected } from "@src/auth/services/auth.service"; +import { StripePricesOutputResponse } from "@src/billing"; +import { StripeService } from "@src/billing/services/stripe/stripe.service"; + +@singleton() +export class StripeController { + constructor(private readonly stripe: StripeService) {} + + @Protected([{ action: "read", subject: "StripePrice" }]) + async findPrices(): Promise { + return { data: await this.stripe.findPrices() }; + } +} 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 route = createRoute({ + method: "get", + path: "/v1/stripe-prices", + summary: "", + request: {}, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: StripePricesResponseOutputSchema + } + } + } + } +}); + +export const stripePricesRouter = new OpenApiHonoHandler(); + +stripePricesRouter.openapi(route, async function routeStripePrices(c) { + await container.resolve(StripeController).findPrices(); + return c.json(await container.resolve(StripeController).findPrices(), 200); +}); diff --git a/apps/api/src/billing/services/billing-config/billing-config.service.ts b/apps/api/src/billing/services/billing-config/billing-config.service.ts new file mode 100644 index 000000000..0c045d4f5 --- /dev/null +++ b/apps/api/src/billing/services/billing-config/billing-config.service.ts @@ -0,0 +1,11 @@ +import { singleton } from "tsyringe"; + +import { envSchema } from "@src/billing/config/env.config"; +import { ConfigService } from "@src/core/services/config/config.service"; + +@singleton() +export class BillingConfigService extends ConfigService { + constructor() { + super({ envSchema }); + } +} diff --git a/apps/api/src/billing/services/checkout/checkout.service.ts b/apps/api/src/billing/services/checkout/checkout.service.ts index 82ad11327..d21e5d461 100644 --- a/apps/api/src/billing/services/checkout/checkout.service.ts +++ b/apps/api/src/billing/services/checkout/checkout.service.ts @@ -4,6 +4,12 @@ import { CheckoutSessionRepository } from "@src/billing/repositories"; import { StripeService } from "@src/billing/services/stripe/stripe.service"; import { UserOutput, UserRepository } from "@src/user/repositories"; +interface CheckoutSessionOptions { + user: UserOutput; + redirectUrl: string; + amount?: string; +} + @singleton() export class CheckoutService { constructor( @@ -12,12 +18,13 @@ export class CheckoutService { private readonly checkoutSessionRepository: CheckoutSessionRepository ) {} - async checkoutFor(user: UserOutput, redirectUrl: string) { + async checkoutFor({ user, redirectUrl, amount }: CheckoutSessionOptions) { const { stripeCustomerId } = await this.ensureCustomer(user); const session = await this.stripe.startCheckoutSession({ customerId: stripeCustomerId, - redirectUrl + redirectUrl, + amount }); await this.checkoutSessionRepository.create({ diff --git a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts index d5a28b010..fce79862e 100644 --- a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts +++ b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts @@ -1,7 +1,7 @@ import { AllowanceHttpService } from "@akashnetwork/http-sdk"; import { LoggerService } from "@akashnetwork/logging"; import { stringToPath } from "@cosmjs/crypto"; -import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; +import { DirectSecp256k1HdWallet, EncodeObject } from "@cosmjs/proto-signing"; import { IndexedTx } from "@cosmjs/stargate"; import add from "date-fns/add"; import { singleton } from "tsyringe"; @@ -12,6 +12,7 @@ import { InjectWallet } from "@src/billing/providers/wallet.provider"; import { MasterSigningClientService } from "@src/billing/services/master-signing-client/master-signing-client.service"; import { MasterWalletService } from "@src/billing/services/master-wallet/master-wallet.service"; import { RpcMessageService, SpendingAuthorizationMsgOptions } from "@src/billing/services/rpc-message-service/rpc-message.service"; +import { DryRunOptions } from "@src/core/types/console"; interface SpendingAuthorizationOptions { address: string; @@ -94,11 +95,9 @@ export class ManagedUserWalletService { } private async authorizeFeeSpending(options: Omit) { - 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 +110,36 @@ export class ManagedUserWalletService { const deploymentAllowanceMsg = this.rpcMessageService.getDepositDeploymentGrantMsg(options); return await this.masterSigningClientService.executeTx([deploymentAllowanceMsg]); } + + async revokeAll(grantee: string, reason?: string, options?: DryRunOptions) { + const masterWalletAddress = await this.masterWalletService.getFirstAddress(); + const params = { granter: masterWalletAddress, grantee }; + const messages: EncodeObject[] = []; + const revokeSummary = { + feeAllowance: false, + deploymentGrant: false + }; + + if (await this.allowanceHttpService.hasFeeAllowance(params.granter, params.grantee)) { + revokeSummary.feeAllowance = true; + messages.push(this.rpcMessageService.getRevokeAllowanceMsg(params)); + } + + if (await this.allowanceHttpService.hasDeploymentGrant(params.granter, params.grantee)) { + revokeSummary.deploymentGrant = true; + messages.push(this.rpcMessageService.getRevokeDepositDeploymentGrantMsg(params)); + } + + if (!messages.length) { + return; + } + + if (!options?.dryRun) { + await this.masterSigningClientService.executeTx(messages); + } + + this.logger.info({ event: "SPENDING_REVOKED", address: params.grantee, revokeSummary, reason }); + + return revokeSummary; + } } diff --git a/apps/api/src/billing/services/provider-cleanup/provider-cleanup.service.ts b/apps/api/src/billing/services/provider-cleanup/provider-cleanup.service.ts new file mode 100644 index 000000000..111697400 --- /dev/null +++ b/apps/api/src/billing/services/provider-cleanup/provider-cleanup.service.ts @@ -0,0 +1,90 @@ +import { LoggerService } from "@akashnetwork/logging"; +import { singleton } from "tsyringe"; + +import { BillingConfig, InjectBillingConfig } from "@src/billing/providers"; +import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; +import { ManagedUserWalletService, RpcMessageService } from "@src/billing/services"; +import { ErrorService } from "@src/core/services/error/error.service"; +import { ProviderCleanupSummarizer } from "@src/deployment/lib/provider-cleanup-summarizer/provider-cleanup-summarizer"; +import { DeploymentRepository } from "@src/deployment/repositories/deployment/deployment.repository"; +import { TxSignerService } from "../tx-signer/tx-signer.service"; + +export interface ProviderCleanupParams { + concurrency: number; + provider: string; + dryRun: boolean; +} + +@singleton() +export class ProviderCleanupService { + private readonly logger = LoggerService.forContext(ProviderCleanupService.name); + + constructor( + @InjectBillingConfig() private readonly config: BillingConfig, + private readonly userWalletRepository: UserWalletRepository, + private readonly managedUserWalletService: ManagedUserWalletService, + private readonly txSignerService: TxSignerService, + private readonly deploymentRepository: DeploymentRepository, + private readonly rpcMessageService: RpcMessageService, + private readonly errorService: ErrorService + ) {} + + async cleanup(options: ProviderCleanupParams) { + const summary = new ProviderCleanupSummarizer(); + await this.userWalletRepository.paginate({ query: { isTrialing: true }, limit: options.concurrency || 10 }, async wallets => { + const cleanUpAllWallets = wallets.map(async wallet => { + await this.errorService.execWithErrorHandler( + { + wallet, + event: "PROVIDER_CLEAN_UP_ERROR", + context: ProviderCleanupService.name + }, + () => this.cleanUpForWallet(wallet, options, summary) + ); + }); + + await Promise.all(cleanUpAllWallets); + }); + + this.logger.info({ event: "PROVIDER_CLEAN_UP_SUMMARY", summary: summary.summarize(), dryRun: options.dryRun }); + } + + private async cleanUpForWallet(wallet: UserWalletOutput, options: ProviderCleanupParams, summary: ProviderCleanupSummarizer) { + const client = await this.txSignerService.getClientForAddressIndex(wallet.id); + const deployments = await this.deploymentRepository.findDeploymentsForProvider({ + owner: wallet.address, + provider: options.provider + }); + + const closeAllWalletStaleDeployments = deployments.map(async deployment => { + const message = this.rpcMessageService.getCloseDeploymentMsg(wallet.address, deployment.dseq); + this.logger.info({ event: "PROVIDER_CLEAN_UP", params: { owner: wallet.address, dseq: deployment.dseq } }); + + try { + if (!options.dryRun) { + await client.signAndBroadcast([message]); + this.logger.info({ event: "PROVIDER_CLEAN_UP_SUCCESS" }); + } + } catch (error) { + if (error.message.includes("not allowed to pay fees")) { + if (!options.dryRun) { + await this.managedUserWalletService.authorizeSpending({ + address: wallet.address, + limits: { + fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT + } + }); + await client.signAndBroadcast([message]); + this.logger.info({ event: "PROVIDER_CLEAN_UP_SUCCESS" }); + } + } else { + throw error; + } + } finally { + summary.inc("deploymentCount"); + } + }); + + await Promise.all(closeAllWalletStaleDeployments); + } +} diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts index 98ec02fef..fadc82993 100644 --- a/apps/api/src/billing/services/refill/refill.service.ts +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -68,6 +68,6 @@ export class RefillService { }); await this.balancesService.refreshUserWalletLimits(userWallet, { endTrial: true }); - this.logger.debug({ event: "WALLET_TOP_UP", limits }); + this.logger.debug({ event: "WALLET_TOP_UP", userWallet, limits }); } } 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/stripe-webhook/stripe-webhook.service.ts b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts index f26cb99b6..a338d374a 100644 --- a/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts +++ b/apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts @@ -41,7 +41,7 @@ export class StripeWebhookService { }); if (checkoutSession.payment_status !== "unpaid") { - await this.refillService.topUpWallet(checkoutSession.amount_total, checkoutSessionCache.userId); + await this.refillService.topUpWallet(checkoutSession.amount_subtotal, checkoutSessionCache.userId); await this.checkoutSessionRepository.deleteBy({ sessionId: event.data.object.id }); } else { this.logger.error({ event: "PAYMENT_NOT_COMPLETED", sessionId }); diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts index 53bf7dda2..2f7558d03 100644 --- a/apps/api/src/billing/services/stripe/stripe.service.ts +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -1,33 +1,74 @@ +import assert from "http-assert"; +import orderBy from "lodash/orderBy"; import Stripe from "stripe"; import { singleton } from "tsyringe"; -import { BillingConfig, InjectBillingConfig } from "@src/billing/providers"; +import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; interface CheckoutOptions { customerId: string; redirectUrl: string; + amount?: string; +} + +interface StripePrices { + unitAmount: number; + isCustom: boolean; + currency: string; } @singleton() export class StripeService extends Stripe { - constructor(@InjectBillingConfig() private readonly billingConfig: BillingConfig) { + constructor(private readonly billingConfig: BillingConfigService) { super(process.env.STRIPE_SECRET_KEY, { apiVersion: "2024-06-20" }); } async startCheckoutSession(options: CheckoutOptions) { + const price = await this.getPrice(options.amount); + return await this.checkout.sessions.create({ line_items: [ { - price: this.billingConfig.STRIPE_PRICE_ID, + price: price.id, quantity: 1 } ], mode: "payment", + allow_promotion_codes: !!options.amount, customer: options.customerId, success_url: `${options.redirectUrl}?session_id={CHECKOUT_SESSION_ID}&payment-success=true`, cancel_url: `${options.redirectUrl}?session_id={CHECKOUT_SESSION_ID}&payment-canceled=true` }); } + + private async getPrice(amount?: string) { + const { data: prices } = await this.prices.list({ product: this.billingConfig.get("STRIPE_PRODUCT_ID") }); + + const price = prices.find(price => { + const isCustom = !amount && !!price.custom_unit_amount; + + if (isCustom) { + return true; + } + + return price.unit_amount === Number(amount) * 100; + }); + + assert(price, 400, "Price invalid"); + + return price; + } + + async findPrices(): Promise { + const { data: prices } = await this.prices.list({ active: true }); + const responsePrices = prices.map(price => ({ + unitAmount: price.custom_unit_amount ? undefined : price.unit_amount / 100, + isCustom: !!price.custom_unit_amount, + currency: price.currency + })); + + return orderBy(responsePrices, ["isCustom", "unitAmount"], ["asc", "asc"]); + } } diff --git a/apps/api/src/billing/services/trial-validation/trial-validation.service.ts b/apps/api/src/billing/services/trial-validation/trial-validation.service.ts index 99f6d791d..0330ec043 100644 --- a/apps/api/src/billing/services/trial-validation/trial-validation.service.ts +++ b/apps/api/src/billing/services/trial-validation/trial-validation.service.ts @@ -3,9 +3,7 @@ import { EncodeObject } from "@cosmjs/proto-signing"; import { singleton } from "tsyringe"; import { UserWalletOutput } from "@src/billing/repositories"; - -const TRIAL_ATTRIBUTE = "console/trials"; -const AUDITOR = "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63"; +import { AUDITOR, TRIAL_ATTRIBUTE } from "@src/deployment/config/provider.config"; @singleton() export class TrialValidationService { 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/chain/repositories/block.repository.ts b/apps/api/src/chain/repositories/block.repository.ts new file mode 100644 index 000000000..1db05809f --- /dev/null +++ b/apps/api/src/chain/repositories/block.repository.ts @@ -0,0 +1,11 @@ +import { Block } from "@akashnetwork/database/dbSchemas/base"; +import { singleton } from "tsyringe"; + +@singleton() +export class BlockRepository { + async getLatestProcessedHeight(): Promise { + const height = await Block.max("height", { where: { isProcessed: true } }); + + return (height as number) ?? 0; + } +} diff --git a/apps/api/src/chain/services/block-http/block-http.service.ts b/apps/api/src/chain/services/block-http/block-http.service.ts index 55e12c4e2..336167364 100644 --- a/apps/api/src/chain/services/block-http/block-http.service.ts +++ b/apps/api/src/chain/services/block-http/block-http.service.ts @@ -6,7 +6,9 @@ import { averageBlockTime } from "@src/utils/constants"; @singleton() export class BlockHttpService { - constructor(private readonly blockHttpService: BlockHttpServiceCommon) {} + constructor( + private readonly blockHttpService: BlockHttpServiceCommon + ) {} @Memoize({ ttlInSeconds: averageBlockTime }) async getCurrentHeight() { diff --git a/apps/api/src/console.ts b/apps/api/src/console.ts index 41b8900bf..115bc32b1 100644 --- a/apps/api/src/console.ts +++ b/apps/api/src/console.ts @@ -7,10 +7,14 @@ import { LoggerService } from "@akashnetwork/logging"; import { context, trace } from "@opentelemetry/api"; import { Command } from "commander"; import { container } from "tsyringe"; +import { z } from "zod"; 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"; +import { ProviderController } from "./deployment/controllers/provider/provider.controller"; const program = new Command(); @@ -28,8 +32,8 @@ program program .command("top-up-deployments") - .option("-d, --dry-run", "Dry run the top up deployments", false) .description("Refill deployments with auto top up enabled") + .option("-d, --dry-run", "Dry run the top up deployments", false) .action(async (options, command) => { await executeCliHandler(command.name(), async () => { await container.resolve(TopUpDeploymentsController).topUpDeployments({ dryRun: options.dryRun }); @@ -39,9 +43,33 @@ program program .command("cleanup-stale-deployments") .description("Close deployments without leases created at least 10min ago") + .option("-c, --concurrency ", "How many wallets is processed concurrently", value => z.number({ coerce: true }).optional().default(10).parse(value)) + .action(async (options, command) => { + await executeCliHandler(command.name(), async () => { + await container.resolve(TopUpDeploymentsController).cleanUpStaleDeployment(options); + }); + }); + +program + .command("cleanup-provider-deployments") + .description("Close trial deployments for a provider") + .option("-c, --concurrency ", "How many wallets is processed concurrently", value => z.number({ coerce: true }).optional().default(10).parse(value)) + .option("-d, --dry-run", "Dry run the trial provider cleanup", false) + .option("-p, --provider ", "Provider address", value => z.string().parse(value)) + .action(async (options, command) => { + await executeCliHandler(command.name(), async () => { + await container.resolve(ProviderController).cleanupProviderDeployments(options); + }); + }); + +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`) + .option("-d, --dry-run", "Dry run the clean up stale anonymous users", false) .action(async (options, command) => { await executeCliHandler(command.name(), async () => { - await container.resolve(TopUpDeploymentsController).cleanUpStaleDeployment(); + await container.resolve(UserController).cleanUpStaleAnonymousUsers({ dryRun: options.dryRun }); }); }); 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/core/types/console.ts b/apps/api/src/core/types/console.ts new file mode 100644 index 000000000..a53d4170a --- /dev/null +++ b/apps/api/src/core/types/console.ts @@ -0,0 +1,3 @@ +export interface DryRunOptions { + dryRun: boolean; +} diff --git a/apps/api/src/deployment/config/config.provider.ts b/apps/api/src/deployment/config/config.provider.ts index 96689da90..a25bc68ea 100644 --- a/apps/api/src/deployment/config/config.provider.ts +++ b/apps/api/src/deployment/config/config.provider.ts @@ -8,4 +8,4 @@ container.register(DEPLOYMENT_CONFIG, { useValue: config }); export type DeploymentConfig = typeof config; -export const InjectDeploymentConfig = () => inject(DEPLOYMENT_CONFIG); +export const InjectDeploymentConfig = () => inject(DEPLOYMENT_CONFIG); \ No newline at end of file diff --git a/apps/api/src/deployment/config/provider.config.ts b/apps/api/src/deployment/config/provider.config.ts new file mode 100644 index 000000000..244bf6e11 --- /dev/null +++ b/apps/api/src/deployment/config/provider.config.ts @@ -0,0 +1,2 @@ +export const TRIAL_ATTRIBUTE = "console/trials"; +export const AUDITOR = "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63"; diff --git a/apps/api/src/deployment/controllers/deployment/deployment.controller.ts b/apps/api/src/deployment/controllers/deployment/deployment.controller.ts index f63b1f09c..b6d8157cc 100644 --- a/apps/api/src/deployment/controllers/deployment/deployment.controller.ts +++ b/apps/api/src/deployment/controllers/deployment/deployment.controller.ts @@ -1,6 +1,9 @@ import { singleton } from "tsyringe"; -import { StaleManagedDeploymentsCleanerService } from "@src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service"; +import { + CleanUpStaleDeploymentsParams, + StaleManagedDeploymentsCleanerService +} from "@src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service"; import { TopUpCustodialDeploymentsService } from "@src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service"; import { TopUpManagedDeploymentsService } from "@src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service"; import { TopUpDeploymentsOptions } from "@src/deployment/types/deployments-refiller"; @@ -18,7 +21,7 @@ export class TopUpDeploymentsController { await this.topUpManagedDeploymentsService.topUpDeployments(options); } - async cleanUpStaleDeployment() { - await this.staleDeploymentsCleanerService.cleanup(); + async cleanUpStaleDeployment(options: CleanUpStaleDeploymentsParams) { + await this.staleDeploymentsCleanerService.cleanup(options); } } diff --git a/apps/api/src/deployment/controllers/provider/provider.controller.ts b/apps/api/src/deployment/controllers/provider/provider.controller.ts new file mode 100644 index 000000000..de90d8154 --- /dev/null +++ b/apps/api/src/deployment/controllers/provider/provider.controller.ts @@ -0,0 +1,20 @@ +import { singleton } from "tsyringe"; + +import { ProviderCleanupParams, ProviderCleanupService } from "@src/billing/services/provider-cleanup/provider-cleanup.service"; +import { TrialProvidersService } from "@src/deployment/services/trial-providers/trial-providers.service"; + +@singleton() +export class ProviderController { + constructor( + private readonly trialProvidersService: TrialProvidersService, + private readonly providerCleanupService: ProviderCleanupService + ) {} + + async getTrialProviders(): Promise { + return await this.trialProvidersService.getTrialProviders(); + } + + async cleanupProviderDeployments(options: ProviderCleanupParams) { + return await this.providerCleanupService.cleanup(options); + } +} diff --git a/apps/api/src/deployment/lib/provider-cleanup-summarizer/provider-cleanup-summarizer.ts b/apps/api/src/deployment/lib/provider-cleanup-summarizer/provider-cleanup-summarizer.ts new file mode 100644 index 000000000..a2e82a84e --- /dev/null +++ b/apps/api/src/deployment/lib/provider-cleanup-summarizer/provider-cleanup-summarizer.ts @@ -0,0 +1,25 @@ +interface ProviderCleanupSummary { + deploymentCount: number; +} + +export class ProviderCleanupSummarizer { + private deploymentCount = 0; + + inc(param: keyof ProviderCleanupSummary, value = 1) { + this[param] += value; + } + + set(param: keyof ProviderCleanupSummary, value: number) { + this[param] = value; + } + + get(param: keyof ProviderCleanupSummary) { + return this[param]; + } + + summarize(): ProviderCleanupSummary { + return { + deploymentCount: this.deploymentCount + }; + } +} diff --git a/apps/api/src/deployment/repositories/deployment/deployment.repository.ts b/apps/api/src/deployment/repositories/deployment/deployment.repository.ts index db10f5c13..373eb1fc2 100644 --- a/apps/api/src/deployment/repositories/deployment/deployment.repository.ts +++ b/apps/api/src/deployment/repositories/deployment/deployment.repository.ts @@ -7,6 +7,11 @@ export interface StaleDeploymentsOptions { owner: string; } +export interface ProviderCleanupOptions { + owner: string; + provider: string; +} + export interface StaleDeploymentsOutput { dseq: number; } @@ -37,4 +42,27 @@ export class DeploymentRepository { return deployments ? (deployments as unknown as StaleDeploymentsOutput[]) : []; } + + async findDeploymentsForProvider(options: ProviderCleanupOptions): Promise { + const deployments = await Deployment.findAll({ + attributes: ["dseq"], + where: { + owner: options.owner, + closedHeight: null + }, + include: [ + { + model: Lease, + attributes: [], + required: true, + where: { + providerAddress: options.provider + } + } + ], + raw: true + }); + + return deployments ? (deployments as unknown as StaleDeploymentsOutput[]) : []; + } } diff --git a/apps/api/src/deployment/repositories/provider/provider.repository.ts b/apps/api/src/deployment/repositories/provider/provider.repository.ts new file mode 100644 index 000000000..3fa9c18b9 --- /dev/null +++ b/apps/api/src/deployment/repositories/provider/provider.repository.ts @@ -0,0 +1,21 @@ +import { ProviderAttributeSignature } from "@akashnetwork/database/dbSchemas/akash"; +import { singleton } from "tsyringe"; + +import { AUDITOR, TRIAL_ATTRIBUTE } from "@src/deployment/config/provider.config"; + +@singleton() +export class ProviderRepository { + async getTrialProviders(): Promise { + const trialProviders = await ProviderAttributeSignature.findAll({ + attributes: ["provider"], + where: { + auditor: AUDITOR, + key: TRIAL_ATTRIBUTE, + value: "true" + }, + raw: true + }); + + return trialProviders.map(provider => provider.provider); + } +} diff --git a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts index 1fe067e01..73922e992 100644 --- a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts +++ b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts @@ -6,11 +6,15 @@ import { BillingConfig, InjectBillingConfig } from "@src/billing/providers"; import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; import { ManagedUserWalletService, RpcMessageService } from "@src/billing/services"; import { TxSignerService } from "@src/billing/services/tx-signer/tx-signer.service"; -import { BlockHttpService } from "@src/chain/services/block-http/block-http.service"; +import { BlockRepository } from "@src/chain/repositories/block.repository"; import { ErrorService } from "@src/core/services/error/error.service"; import { DeploymentRepository } from "@src/deployment/repositories/deployment/deployment.repository"; import { averageBlockTime } from "@src/utils/constants"; +export interface CleanUpStaleDeploymentsParams { + concurrency: number; +} + @singleton() export class StaleManagedDeploymentsCleanerService { private readonly logger = LoggerService.forContext(StaleManagedDeploymentsCleanerService.name); @@ -20,7 +24,7 @@ export class StaleManagedDeploymentsCleanerService { constructor( private readonly userWalletRepository: UserWalletRepository, private readonly deploymentRepository: DeploymentRepository, - private readonly blockHttpService: BlockHttpService, + private readonly blockRepository: BlockRepository, private readonly rpcMessageService: RpcMessageService, private readonly txSignerService: TxSignerService, @InjectBillingConfig() private readonly config: BillingConfig, @@ -28,8 +32,8 @@ export class StaleManagedDeploymentsCleanerService { private readonly errorService: ErrorService ) {} - async cleanup() { - await this.userWalletRepository.paginate({ limit: 10 }, async wallets => { + async cleanup(options: CleanUpStaleDeploymentsParams) { + await this.userWalletRepository.paginate({ limit: options.concurrency || 10 }, async wallets => { const cleanUpAllWallets = wallets.map(async wallet => { await this.errorService.execWithErrorHandler( { @@ -46,7 +50,7 @@ export class StaleManagedDeploymentsCleanerService { } private async cleanUpForWallet(wallet: UserWalletOutput) { - const currentHeight = await this.blockHttpService.getCurrentHeight(); + const currentHeight = await this.blockRepository.getLatestProcessedHeight(); const client = await this.txSignerService.getClientForAddressIndex(wallet.id); const deployments = await this.deploymentRepository.findStaleDeployments({ owner: wallet.address, diff --git a/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts b/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts index 38428f58c..bd8572510 100644 --- a/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts +++ b/apps/api/src/deployment/services/top-up-managed-deployments/top-up-managed-deployments.service.ts @@ -48,7 +48,7 @@ export class TopUpManagedDeploymentsService implements DeploymentsRefiller { }); summary.set("endBlockHeight", await this.blockHttpService.getCurrentHeight()); - this.logger.info({ event: "TOP_UP_SUMMARY", summary: summary.summarize() }); + this.logger.info({ event: "TOP_UP_SUMMARY", summary: summary.summarize(), dryRun: options.dryRun }); } private async topUpForWallet(wallet: UserWalletOutput, options: TopUpDeploymentsOptions, summary: TopUpSummarizer) { diff --git a/apps/api/src/deployment/services/trial-providers/trial-providers.service.ts b/apps/api/src/deployment/services/trial-providers/trial-providers.service.ts new file mode 100644 index 000000000..7f44510c8 --- /dev/null +++ b/apps/api/src/deployment/services/trial-providers/trial-providers.service.ts @@ -0,0 +1,12 @@ +import { singleton } from "tsyringe"; + +import { ProviderRepository } from "@src/deployment/repositories/provider/provider.repository"; + +@singleton() +export class TrialProvidersService { + constructor(private readonly providerRepository: ProviderRepository) {} + + async getTrialProviders(): Promise { + return await this.providerRepository.getTrialProviders(); + } +} diff --git a/apps/api/src/deployment/types/deployments-refiller.ts b/apps/api/src/deployment/types/deployments-refiller.ts index cddc764dd..c49df7631 100644 --- a/apps/api/src/deployment/types/deployments-refiller.ts +++ b/apps/api/src/deployment/types/deployments-refiller.ts @@ -1,6 +1,6 @@ -export interface TopUpDeploymentsOptions { - dryRun: boolean; -} +import { DryRunOptions } from "@src/core/types/console"; + +export interface TopUpDeploymentsOptions extends DryRunOptions {} export interface DeploymentsRefiller { topUpDeployments(options: TopUpDeploymentsOptions): Promise; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c058d6fbc..fba41b0de 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,15 +2,12 @@ import "reflect-metadata"; import "@akashnetwork/env-loader"; import "./open-telemetry"; -async function bootstrap() { - /* eslint-disable @typescript-eslint/no-var-requires */ - if (process.env.BILLING_ENABLED === "true") { - const pg = require("./core"); - await pg.migratePG(); - } +import { initApp } from "./app"; +import { migratePG } from "./core"; - const entry = require("./app"); - await entry.initApp(); +async function bootstrap() { + await migratePG(); + await initApp(); } bootstrap(); diff --git a/apps/api/src/routers/userRouter.ts b/apps/api/src/routers/userRouter.ts index 57a36ce52..970f797f8 100644 --- a/apps/api/src/routers/userRouter.ts +++ b/apps/api/src/routers/userRouter.ts @@ -3,6 +3,7 @@ import assert from "http-assert"; import { container } from "tsyringe"; import * as uuid from "uuid"; +import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service"; import { getCurrentUserId, optionalUserMiddleware, requiredUserMiddleware } from "@src/middlewares/userMiddleware"; import { addTemplateFavorite, @@ -53,12 +54,6 @@ userRequiredRouter.post("/tokenInfo", async c => { }); async function extractAnonymousUserId(c: Context) { - if (process.env.BILLING_ENABLED !== "true") { - return; - } - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { AuthTokenService } = require("@src/auth/services/auth-token/auth-token.service"); - const anonymousBearer = c.req.header("x-anonymous-authorization"); if (anonymousBearer) { diff --git a/apps/api/src/routes/v1/index.ts b/apps/api/src/routes/v1/index.ts index e02da0b1d..eed2e40a1 100644 --- a/apps/api/src/routes/v1/index.ts +++ b/apps/api/src/routes/v1/index.ts @@ -12,6 +12,9 @@ import proposals from "./proposals/list"; import providerByAddress from "./providers/byAddress"; import providerDeployments from "./providers/deployments"; import providerList from "./providers/list"; +import templateById from "./templates/byId"; +import templateList from "./templates/list"; +import templateListFull from "./templates/list-full"; import transactionByHash from "./transactions/byHash"; import transactions from "./transactions/list"; import validatorByAddress from "./validators/byAddress"; @@ -31,7 +34,6 @@ import providerActiveLeasesGraphData from "./providerActiveLeasesGraphData"; import providerAttributesSchema from "./providerAttributesSchema"; import providerGraphData from "./providerGraphData"; import providerRegions from "./providerRegions"; -import templates from "./templates"; import trialProviders from "./trialProviders"; export default [ @@ -53,7 +55,9 @@ export default [ validatorByAddress, proposals, proposalById, - templates, + templateListFull, + templateList, + templateById, networkCapacity, marketData, dashboardData, diff --git a/apps/api/src/routes/v1/templates/byId.ts b/apps/api/src/routes/v1/templates/byId.ts new file mode 100644 index 000000000..79afe58a5 --- /dev/null +++ b/apps/api/src/routes/v1/templates/byId.ts @@ -0,0 +1,55 @@ +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; + +import { getCachedTemplateById } from "@src/services/external/templateReposService"; + +const route = createRoute({ + method: "get", + path: "/templates/{id}", + tags: ["Other"], + request: { + params: z.object({ + id: z.string().openapi({ + description: "Template ID", + example: "akash-network-cosmos-omnibus-agoric" + }) + }) + }, + responses: { + 200: { + description: "Return a template by id", + content: { + "application/json": { + schema: z.object({ + id: z.string(), + name: z.string(), + path: z.string(), + logoUrl: z.string().nullable(), + summary: z.string(), + readme: z.string().nullable(), + deploy: z.string(), + persistentStorageEnabled: z.boolean(), + guide: z.string().nullable(), + githubUrl: z.string(), + config: z.object({ + ssh: z.boolean().optional() + }) + }) + } + } + }, + 404: { + description: "Template not found" + } + } +}); + +export default new OpenAPIHono().openapi(route, async c => { + const templateId = c.req.valid("param").id; + const template = await getCachedTemplateById(templateId); + + if (!template) { + return c.text("Template not found", 404); + } + + return c.json(template); +}); diff --git a/apps/api/src/routes/v1/templates.ts b/apps/api/src/routes/v1/templates/list-full.ts similarity index 78% rename from apps/api/src/routes/v1/templates.ts rename to apps/api/src/routes/v1/templates/list-full.ts index 9ce8b335e..37a1abdd0 100644 --- a/apps/api/src/routes/v1/templates.ts +++ b/apps/api/src/routes/v1/templates/list-full.ts @@ -1,7 +1,6 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; -import { cacheKeys, cacheResponse } from "@src/caching/helpers"; -import { getTemplateGallery } from "@src/services/external/templateReposService"; +import { getCachedTemplatesGallery } from "@src/services/external/templateReposService"; const route = createRoute({ method: "get", @@ -9,7 +8,7 @@ const route = createRoute({ tags: ["Other"], responses: { 200: { - description: "Returns a list of deployment templates grouped by cateogories", + description: "Returns a list of deployment templates grouped by categories", content: { "application/json": { schema: z.array( @@ -40,7 +39,10 @@ const route = createRoute({ } }); +/** + * @deprecated should stay for some time in order to let UI to migrate to shorten list version. + */ export default new OpenAPIHono().openapi(route, async c => { - const response = await cacheResponse(60 * 5, cacheKeys.getTemplates, async () => await getTemplateGallery(), true); - return c.json(response); + const templatesPerCategory = await getCachedTemplatesGallery(); + return c.json(templatesPerCategory); }); diff --git a/apps/api/src/routes/v1/templates/list.ts b/apps/api/src/routes/v1/templates/list.ts new file mode 100644 index 000000000..4f853b3a5 --- /dev/null +++ b/apps/api/src/routes/v1/templates/list.ts @@ -0,0 +1,42 @@ +import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; + +import { getCachedTemplatesGallery } from "@src/services/external/templateReposService"; + +const responseSchema = z.array( + z.object({ + title: z.string(), + templates: z.array( + z.object({ + id: z.string(), + name: z.string(), + logoUrl: z.string().nullable(), + summary: z.string(), + }) + ) + }) +); +const route = createRoute({ + method: "get", + path: "/templates-list", + tags: ["Other"], + responses: { + 200: { + description: "Returns a list of deployment templates grouped by categories", + content: { + "application/json": { + schema: responseSchema + } + } + } + } +}); + +export default new OpenAPIHono().openapi(route, async c => { + const templatesPerCategory = await getCachedTemplatesGallery(); + // TODO: remove manual response filtering when https://github.com/honojs/middleware/issues/181 is done + const filteredTemplatesPerCategory = await responseSchema.safeParseAsync(templatesPerCategory); + const response = filteredTemplatesPerCategory.success + ? filteredTemplatesPerCategory.data + : templatesPerCategory; + return c.json(response); +}); diff --git a/apps/api/src/routes/v1/trialProviders.ts b/apps/api/src/routes/v1/trialProviders.ts index e786757be..2f5f2650f 100644 --- a/apps/api/src/routes/v1/trialProviders.ts +++ b/apps/api/src/routes/v1/trialProviders.ts @@ -1,6 +1,7 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import { container } from "tsyringe"; -import { getTrialProviders } from "@src/services/external/githubService"; +import { ProviderController } from "@src/deployment/controllers/provider/provider.controller"; const route = createRoute({ method: "get", @@ -20,6 +21,6 @@ const route = createRoute({ }); export default new OpenAPIHono().openapi(route, async c => { - const response = await getTrialProviders(); - return c.json(response); -}); + const response = await container.resolve(ProviderController).getTrialProviders(); + return c.json(response, 200); +}); \ No newline at end of file diff --git a/apps/api/src/services/db/userDataService.ts b/apps/api/src/services/db/userDataService.ts index ccfda6e13..52f9e35f9 100644 --- a/apps/api/src/services/db/userDataService.ts +++ b/apps/api/src/services/db/userDataService.ts @@ -4,6 +4,8 @@ import pick from "lodash/pick"; import { Transaction } from "sequelize"; import { container } from "tsyringe"; +import { UserWalletRepository } from "@src/billing/repositories"; + const logger = LoggerService.forContext("UserDataService"); function randomIntFromInterval(min: number, max: number) { @@ -150,12 +152,6 @@ export async function getSettingsOrInit({ anonymousUserId, userId, wantedUsernam } async function tryToTransferWallet(prevUserId: string, nextUserId: string) { - if (process.env.BILLING_ENABLED !== "true") { - return; - } - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { UserWalletRepository } = require("@src/billing/repositories/user-wallet/user-wallet.repository"); - const userWalletRepository = container.resolve(UserWalletRepository); try { diff --git a/apps/api/src/services/external/githubService.ts b/apps/api/src/services/external/githubService.ts index 3aee4018d..19728232e 100644 --- a/apps/api/src/services/external/githubService.ts +++ b/apps/api/src/services/external/githubService.ts @@ -45,17 +45,3 @@ export async function getAuditors() { return response; } - -export async function getTrialProviders() { - const response = await cacheResponse( - minutesToSeconds(5), - cacheKeys.getTrialProviders, - async () => { - const res = await axios.get("https://raw.githubusercontent.com/akash-network/console/main/config/trial-providers.json"); - return res.data; - }, - true - ); - - return response; -} diff --git a/apps/api/src/services/external/templateReposService.ts b/apps/api/src/services/external/templateReposService.ts index eb08afa76..096573809 100644 --- a/apps/api/src/services/external/templateReposService.ts +++ b/apps/api/src/services/external/templateReposService.ts @@ -4,6 +4,7 @@ import { markdownToTxt } from "markdown-to-txt"; import fetch from "node-fetch"; import path from "path"; +import { cacheKeys, cacheResponse } from "@src/caching/helpers"; import { GithubChainRegistryChainResponse } from "@src/types"; import { GithubDirectoryItem } from "@src/types/github"; import { dataFolderPath } from "@src/utils/constants"; @@ -129,6 +130,21 @@ export const getTemplateGallery = async () => { } }; +export const getCachedTemplatesGallery = (): Promise => cacheResponse(60 * 5, cacheKeys.getTemplates, () => getTemplateGallery(), true); + +export const getTemplateById = async (id: Required