mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-03 16:10:31 +00:00
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:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
+3
-4
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
import './eventListeners/subscriptionEvents.js';
|
||||
import './routes/usage.js';
|
||||
@@ -0,0 +1,4 @@
|
||||
import './eventListeners/subscriptionEvents.js';
|
||||
import { registerUsageController } from './controllers/UsageController.js';
|
||||
|
||||
registerUsageController();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user