From b1e7bc5fca8b93dae82d161a378ffba38f553bfc Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 7 Jan 2026 12:53:00 -0800 Subject: [PATCH] feat: method for updating users metering directly (#2252) * chore: cleanup ts extensions controller * feat: method for updating users metering directly --- extensions/extensionController/package.json | 23 +++ extensions/extensionController/puter.json | 3 + .../src/ExtensionController.ts | 149 ++++++++++++++++++ extensions/extensionController/src/index.ts | 15 ++ extensions/extensionController/tsconfig.json | 28 ++++ extensions/metering/config.json | 3 +- .../metering/controllers/UsageController.ts | 66 ++++++++ ...riptionEvents.js => subscriptionEvents.ts} | 7 +- extensions/metering/main.js | 2 - extensions/metering/main.ts | 4 + extensions/metering/package.json | 10 +- extensions/metering/routes/usage.js | 56 ------- extensions/metering/tsconfig.json | 28 ++++ extensions/metering/types.ts | 1 + .../MeteringService/MeteringService.ts | 76 ++++++++- 15 files changed, 405 insertions(+), 66 deletions(-) create mode 100644 extensions/extensionController/package.json create mode 100644 extensions/extensionController/puter.json create mode 100644 extensions/extensionController/src/ExtensionController.ts create mode 100644 extensions/extensionController/src/index.ts create mode 100644 extensions/extensionController/tsconfig.json create mode 100644 extensions/metering/controllers/UsageController.ts rename extensions/metering/eventListeners/{subscriptionEvents.js => subscriptionEvents.ts} (69%) delete mode 100644 extensions/metering/main.js create mode 100644 extensions/metering/main.ts delete mode 100644 extensions/metering/routes/usage.js create mode 100644 extensions/metering/tsconfig.json create mode 100644 extensions/metering/types.ts diff --git a/extensions/extensionController/package.json b/extensions/extensionController/package.json new file mode 100644 index 000000000..0f5a2f7cf --- /dev/null +++ b/extensions/extensionController/package.json @@ -0,0 +1,23 @@ +{ + "name": "@puter/extension-controller", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "type": "module", + "scripts": { + "postinstall": "tsc --noCheck" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^24.9.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "dependencies": { + "http-status-codes": "^2.3.0", + "stripe": "^19.1.0" + } +} \ No newline at end of file diff --git a/extensions/extensionController/puter.json b/extensions/extensionController/puter.json new file mode 100644 index 000000000..5fce7a8c0 --- /dev/null +++ b/extensions/extensionController/puter.json @@ -0,0 +1,3 @@ +{ + "priority": -10 +} \ No newline at end of file diff --git a/extensions/extensionController/src/ExtensionController.ts b/extensions/extensionController/src/ExtensionController.ts new file mode 100644 index 000000000..6ce1acc3d --- /dev/null +++ b/extensions/extensionController/src/ExtensionController.ts @@ -0,0 +1,149 @@ +import type { RequestHandler } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import type { + EndpointOptions, + HttpMethod, + RouterMethods, +} from '../../api.d.ts'; +/** + * Class decorator to set prefix on prototype and register routes on instantiation + * @argument prefix - prefix for all routes under the class + * @argument [adminUsernames] - gate all routes behind admin username check + */ +export const Controller = ( + prefix: string, + adminUsernames?: string[], +): ClassDecorator => { + return (target: Function) => { + target.prototype.__controllerPrefix = prefix; + target.prototype.__adminUsernames = adminUsernames + ? [...adminUsernames, 'admin', 'system'] + : undefined; + }; +}; + +/** + * Method decorator factory that collects route metadata + */ +interface RouteMeta { + method: HttpMethod; + path: string; + options?: EndpointOptions | undefined; + handler: RequestHandler; + adminUsernames?: string[]; +} + +const createMethodDecorator = (method: HttpMethod) => { + return ( + path: string, + options?: EndpointOptions, + adminUsernames?: string[], + ) => { + return < + P extends Record = Record< + string, + string | undefined + >, + >( + target: RequestHandler

, + _context: ClassMethodDecoratorContext< + This, + ( + this: This, + ...args: Parameters> + ) => ReturnType> + >, + ) => { + _context.addInitializer(function () { + // eslint-disable-next-line no-invalid-this + const proto = Object.getPrototypeOf(this); // will be bound to class + if ( ! proto.__routes ) { + proto.__routes = []; + } + proto.__routes.push({ + method, + path, + options: options as EndpointOptions | undefined, + adminUsernames: adminUsernames + ? [...adminUsernames, 'admin', 'system'] + : undefined, + handler: target, + }); + }); + }; + }; +}; + +// HTTP method decorators +export const Get = createMethodDecorator('get'); +export const Post = createMethodDecorator('post'); +export const Put = createMethodDecorator('put'); +export const Delete = createMethodDecorator('delete'); +// TODO DS: add others as needed (patch, etc) + +export class HttpError extends Error { + statusCode: number; + constructor (statusCode: StatusCodes, message: string, cause?: unknown) { + super(`${statusCode} - ${message}`, { cause }); + this.statusCode = statusCode; + } +} + +// Registers all routes from a decorated controller instance to an Express router +export class ExtensionController { + // TODO DS: make this work with other express-like routers + registerRoutes () { + const prefix = Object.getPrototypeOf(this).__controllerPrefix || ''; + const adminsForController = Object.getPrototypeOf(this).__adminUsernames as + | string[] + | undefined; + const routes: RouteMeta[] = Object.getPrototypeOf(this).__routes || []; + for ( const route of routes ) { + const fullPath = `${prefix}/${route.path}`.replace(/\/+/g, '/'); + const adminsForRoute = route.adminUsernames + ? adminsForController + ? adminsForController.concat(route.adminUsernames) + : route.adminUsernames + : adminsForController + ? adminsForController + : undefined; + if ( ! extension[route.method] ) { + throw new Error(`Unsupported HTTP method: ${route.method}`); + } else { + console.log(`Registering route: [${route.method.toUpperCase()}] ${fullPath}`); + + (extension[route.method] as RouterMethods[HttpMethod])( + fullPath, + route.options || {}, + async (req, res, next) => { + try { + if ( adminsForRoute ) { + if ( ! adminsForRoute.includes(req.actor.type.user.username) ) { + throw new HttpError(StatusCodes.UNAUTHORIZED, + 'Only admins may request this resource.'); + } + } + await route.handler.bind(this)(req, res, next); + } catch ( error ) { + if ( error instanceof HttpError ) { + res.status(error.statusCode).send({ error: error.message }); + console.error('httpError:', error); + return; + } + if ( error instanceof Error ) { + res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .send({ error: error.message }); + console.error('Non-http error:', error); + return; + } + res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .send({ error: 'An unknown error occurred' }); + console.error('An unknown error occurred:', error); + } + }); + } + } + } +} diff --git a/extensions/extensionController/src/index.ts b/extensions/extensionController/src/index.ts new file mode 100644 index 000000000..d4d276998 --- /dev/null +++ b/extensions/extensionController/src/index.ts @@ -0,0 +1,15 @@ +import { Controller, Delete, ExtensionController, Get, HttpError, Post, Put } from './ExtensionController.js'; + +extension.exports = { + ExtensionController, + Controller, + Get, + Put, + Post, + Delete, + HttpError, +}; + +export { + Controller, Delete, ExtensionController, Get, HttpError, Post, Put, +}; diff --git a/extensions/extensionController/tsconfig.json b/extensions/extensionController/tsconfig.json new file mode 100644 index 000000000..c9cbd48a9 --- /dev/null +++ b/extensions/extensionController/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true, + "noEmitOnError": true, + "noImplicitAny": false, + "allowJs": true, + "checkJs": false, + }, + "include": [ + "./**/*.ts", + "./**/*.d.ts" + ], + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/test/**", + "**/tests/**", + "node_modules", + "dist", + "*.js" + ] +} \ No newline at end of file diff --git a/extensions/metering/config.json b/extensions/metering/config.json index 2c8baa8bc..1624a4ee3 100644 --- a/extensions/metering/config.json +++ b/extensions/metering/config.json @@ -6,5 +6,6 @@ "allowedGlobalUsageUsers": [ "06ab2f87-aef5-441b-9c60-debbb8d24dda", "d8fd169b-4e93-484a-bd84-115b5a2f0ed4" - ] + ], + "priority": 10 } \ No newline at end of file diff --git a/extensions/metering/controllers/UsageController.ts b/extensions/metering/controllers/UsageController.ts new file mode 100644 index 000000000..dfcaa4bbe --- /dev/null +++ b/extensions/metering/controllers/UsageController.ts @@ -0,0 +1,66 @@ +import { MeteringService } from '@heyputer/backend/src/services/MeteringService/MeteringService.js'; + +const { Controller, Get, ExtensionController } = extension.import('extensionController'); + +@Controller('/metering') +export class UsageController extends ExtensionController { + + #meteringService: MeteringService; + + constructor (meteringService: MeteringService) { + super(); + this.#meteringService = meteringService; + } + + @Get('usage', { subdomain: 'api' }) + async getUsage (req, res) { + const actor = req.actor; + if ( ! actor ) { + throw Error('actor not found in context'); + } + const actorUsagePromise = this.#meteringService.getActorCurrentMonthUsageDetails(actor); + const actorAllowanceInfoPromise = this.#meteringService.getAllowedUsage(actor); + + const [actorUsage, allowanceInfo] = await Promise.all([ + actorUsagePromise, + actorAllowanceInfoPromise, + ]); + res.status(200).json({ ...actorUsage, allowanceInfo }); + return; + } + + @Get('usage/:appId', { subdomain: 'api' }) + async getUsageByApp (req, res) { + const actor = req.actor; + if ( ! actor ) { + throw Error('actor not found in context'); + } + const appId = req.params.appId; + if ( ! appId ) { + res.status(400).json({ error: 'appId parameter is required' }); + return; + } + + const appUsage = await this.#meteringService.getActorCurrentMonthAppUsageDetails(actor, appId); + res.status(200).json(appUsage); + return; + } + + @Get('globalUsage', { subdomain: 'api' }, extension.config.allowedGlobalUsageUsers || []) + async getGlobalUsage (req, res) { + const actor = req.actor; + if ( ! actor ) { + throw Error('actor not found in context'); + } + + const globalUsage = await this.#meteringService.getGlobalUsage(); + res.status(200).json(globalUsage); + return; + } +} + +export const registerUsageController = () => { + const controller = new UsageController(extension.import('service:meteringService')); + controller.registerRoutes(); + console.debug('Loaded /metering/usage routes'); +}; diff --git a/extensions/metering/eventListeners/subscriptionEvents.js b/extensions/metering/eventListeners/subscriptionEvents.ts similarity index 69% rename from extensions/metering/eventListeners/subscriptionEvents.js rename to extensions/metering/eventListeners/subscriptionEvents.ts index 2e5662a21..e15f8f717 100644 --- a/extensions/metering/eventListeners/subscriptionEvents.js +++ b/extensions/metering/eventListeners/subscriptionEvents.ts @@ -1,4 +1,4 @@ -extension.on('metering:overrideDefaultSubscription', async (/** @type {{actor: import('@heyputer/backend/src/services/auth/Actor').Actor, defaultSubscription: string}} */event) => { +extension.on('metering:overrideDefaultSubscription', async (event) => { // bit of a stub implementation for OSS, technically can be always free if you set this config true if ( config.unlimitedUsage ) { console.warn('WARNING!!! unlimitedUsage is enabled, this is not recommended for production use'); @@ -6,8 +6,7 @@ extension.on('metering:overrideDefaultSubscription', async (/** @type {{actor: i } }); -extension.on('metering:registerAvailablePolicies', async ( - /** @type {{actor: import('@heyputer/backend/src/services/auth/Actor').Actor, availablePolicies: unknown[]}} */event) => { +extension.on('metering:registerAvailablePolicies', async (event) => { // bit of a stub implementation for OSS, technically can be always free if you set this config true if ( config.unlimitedUsage || config.unlimitedAllowList?.length ) { event.availablePolicies.push({ @@ -18,7 +17,7 @@ extension.on('metering:registerAvailablePolicies', async ( } }); -extension.on('metering:getUserSubscription', async (/** @type {{actor: import('@heyputer/backend/src/services/auth/Actor').Actor, userSubscriptionId: string}} */event) => { +extension.on('metering:getUserSubscription', async (event) => { const userName = event?.actor?.type?.user?.username; if ( config.unlimitedAllowList?.includes(userName) ) { event.userSubscriptionId; diff --git a/extensions/metering/main.js b/extensions/metering/main.js deleted file mode 100644 index b376a793c..000000000 --- a/extensions/metering/main.js +++ /dev/null @@ -1,2 +0,0 @@ -import './eventListeners/subscriptionEvents.js'; -import './routes/usage.js'; diff --git a/extensions/metering/main.ts b/extensions/metering/main.ts new file mode 100644 index 000000000..11c276cd1 --- /dev/null +++ b/extensions/metering/main.ts @@ -0,0 +1,4 @@ +import './eventListeners/subscriptionEvents.js'; +import { registerUsageController } from './controllers/UsageController.js'; + +registerUsageController(); diff --git a/extensions/metering/package.json b/extensions/metering/package.json index a574e2b5d..47c7f2e17 100644 --- a/extensions/metering/package.json +++ b/extensions/metering/package.json @@ -1,5 +1,11 @@ { "name": "@heyputer/extension-metering-service", "main": "main.js", - "type": "module" -} \ No newline at end of file + "type": "module", + "scripts": { + "postinstall": "tsc --noCheck" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/extensions/metering/routes/usage.js b/extensions/metering/routes/usage.js deleted file mode 100644 index 12b6f50f2..000000000 --- a/extensions/metering/routes/usage.js +++ /dev/null @@ -1,56 +0,0 @@ -const meteringServiceWrapper = extension.import('service:meteringService'); - -// TODO DS: move this to its own router and just use under this path -extension.get('/metering/usage', { subdomain: 'api' }, async (req, res) => { - const meteringService = meteringServiceWrapper.meteringService; - - const actor = req.actor; - if ( ! actor ) { - throw Error('actor not found in context'); - } - const actorUsagePromise = meteringService.getActorCurrentMonthUsageDetails(actor); - const actorAllowanceInfoPromise = meteringService.getAllowedUsage(actor); - - const [actorUsage, allowanceInfo] = await Promise.all([actorUsagePromise, actorAllowanceInfoPromise]); - res.status(200).json({ ...actorUsage, allowanceInfo }); - return; -}); - -extension.get('/metering/usage/:appId', { subdomain: 'api' }, async (req, res) => { - const meteringService = meteringServiceWrapper.meteringService; - - const actor = req.actor; - if ( ! actor ) { - throw Error('actor not found in context'); - } - const appId = req.params.appId; - if ( ! appId ) { - res.status(400).json({ error: 'appId parameter is required' }); - return; - } - - const appUsage = await meteringService.getActorCurrentMonthAppUsageDetails(actor, appId); - res.status(200).json(appUsage); - return; -}); - -extension.get('/metering/globalUsage', { subdomain: 'api' }, async (req, res) => { - const meteringService = meteringServiceWrapper.meteringService; - const actor = req.actor; - if ( ! actor ) { - throw Error('actor not found in context'); - } - - // check if actor is allowed to view global usage - const allowedUsers = extension.config.allowedGlobalUsageUsers || []; - if ( ! allowedUsers.includes(actor.type?.user.uuid) ) { - res.status(403).json({ error: 'You are not authorized to view global usage' }); - return; - } - - const globalUsage = await meteringService.getGlobalUsage(); - res.status(200).json(globalUsage); - return; -}); - -console.debug('Loaded /metering/usage route'); \ No newline at end of file diff --git a/extensions/metering/tsconfig.json b/extensions/metering/tsconfig.json new file mode 100644 index 000000000..3e9daf662 --- /dev/null +++ b/extensions/metering/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true, + "noEmitOnError": true, + "noImplicitAny": false, + "allowJs": true, + "checkJs": false, + }, + "include": [ + "./**/*.ts", + "./**/*.d.ts" + ], + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/test/**", + "**/tests/**", + "node_modules", + "dist", + "*.js" + ] +} diff --git a/extensions/metering/types.ts b/extensions/metering/types.ts new file mode 100644 index 000000000..69a5a7cfd --- /dev/null +++ b/extensions/metering/types.ts @@ -0,0 +1 @@ +import '../api.js'; diff --git a/src/backend/src/services/MeteringService/MeteringService.ts b/src/backend/src/services/MeteringService/MeteringService.ts index 9735d4dac..0fcc7b825 100644 --- a/src/backend/src/services/MeteringService/MeteringService.ts +++ b/src/backend/src/services/MeteringService/MeteringService.ts @@ -405,6 +405,80 @@ export class MeteringService { }); } + async setActorCurrentMonthUsageTotal (actor: Actor, totalCost: number) { + if ( ! actor.type?.user?.uuid ) { + throw new Error('Actor must be a user to set usage details'); + } + if ( !Number.isFinite(totalCost) || totalCost < 0 ) { + throw new Error('Total cost must be a non-negative number'); + } + + const normalizedTotal = Math.round(totalCost); + const currentMonth = this.#getMonthYearString(); + const userId = actor.type.user.uuid; + const appId = actor.type?.app?.uid || GLOBAL_APP_KEY; + + return await this.#superUserService.sudo(async () => { + const actorUsageKey = `${METRICS_PREFIX}:actor:${userId}:${currentMonth}`; + const currentUsage = await this.#kvStore.get({ key: actorUsageKey }) as UsageByType | null; + const currentTotal = currentUsage?.total ?? 0; + const delta = normalizedTotal - currentTotal; + + if ( delta === 0 ) { + return currentUsage || { total: 0 } as UsageByType; + } + + const pathAndAmountMap = { + total: delta, + 'manual_adjustment.cost': delta, + 'manual_adjustment.units': delta, + 'manual_adjustment.count': 1, + }; + + const updatedUsage = await this.#kvStore.incr({ + key: actorUsageKey, + pathAndAmountMap, + }) as unknown as UsageByType; + + const puterConsumptionKey = this.#generateGloabalUsageKey(userId, appId, currentMonth); + this.#kvStore.incr({ + key: puterConsumptionKey, + pathAndAmountMap, + }).catch((e: Error) => { + console.warn('Failed to increment aux usage data \'puterConsumptionKey\' with error: ', e); + }); + + const actorAppUsageKey = `${METRICS_PREFIX}:actor:${userId}:app:${appId}:${currentMonth}`; + this.#kvStore.incr({ + key: actorAppUsageKey, + pathAndAmountMap, + }).catch((e: Error) => { + console.warn('Failed to increment aux usage data \'actorAppUsageKey\' with error: ', e); + }); + + const actorAppTotalsKey = `${METRICS_PREFIX}:actor:${userId}:apps:${currentMonth}`; + this.#kvStore.incr({ + key: actorAppTotalsKey, + pathAndAmountMap: { + [`${appId}.total`]: delta, + [`${appId}.count`]: 1, + }, + }).catch((e: Error) => { + console.warn('Failed to increment aux usage data \'actorAppTotalsKey\' with error: ', e); + }); + + const lastUpdatedKey = `${METRICS_PREFIX}:actor:${userId}:lastUpdated`; + this.#kvStore.set({ + key: lastUpdatedKey, + value: Date.now(), + }).catch((e: Error) => { + console.warn('Failed to set lastUpdatedKey with error: ', e); + }); + + return updatedUsage; + }); + } + async getActorCurrentMonthAppUsageDetails (actor: Actor, appId?: string) { if ( ! actor.type?.user?.uuid ) { throw new Error('Actor must be a user to get usage details'); @@ -557,4 +631,4 @@ export class MeteringService { }); }); } -} \ No newline at end of file +}