feat: method for updating users metering directly (#2252)

* chore: cleanup ts extensions controller

* feat: method for updating users metering directly
This commit is contained in:
Daniel Salazar
2026-01-07 12:53:00 -08:00
committed by GitHub
parent e20daf276b
commit b1e7bc5fca
15 changed files with 405 additions and 66 deletions
@@ -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"
}
}
@@ -0,0 +1,3 @@
{
"priority": -10
}
@@ -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 <This>(
path: string,
options?: EndpointOptions,
adminUsernames?: string[],
) => {
return <
P extends Record<string, string | undefined> = Record<
string,
string | undefined
>,
>(
target: RequestHandler<P>,
_context: ClassMethodDecoratorContext<
This,
(
this: This,
...args: Parameters<RequestHandler<P>>
) => ReturnType<RequestHandler<P>>
>,
) => {
_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);
}
});
}
}
}
}
@@ -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,
};
@@ -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"
]
}
+2 -1
View File
@@ -6,5 +6,6 @@
"allowedGlobalUsageUsers": [
"06ab2f87-aef5-441b-9c60-debbb8d24dda",
"d8fd169b-4e93-484a-bd84-115b5a2f0ed4"
]
],
"priority": 10
}
@@ -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');
};
@@ -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;
-2
View File
@@ -1,2 +0,0 @@
import './eventListeners/subscriptionEvents.js';
import './routes/usage.js';
+4
View File
@@ -0,0 +1,4 @@
import './eventListeners/subscriptionEvents.js';
import { registerUsageController } from './controllers/UsageController.js';
registerUsageController();
+8 -2
View File
@@ -1,5 +1,11 @@
{
"name": "@heyputer/extension-metering-service",
"main": "main.js",
"type": "module"
}
"type": "module",
"scripts": {
"postinstall": "tsc --noCheck"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
-56
View File
@@ -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');
+28
View File
@@ -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"
]
}
+1
View File
@@ -0,0 +1 @@
import '../api.js';
@@ -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 {
});
});
}
}
}