From 645540aa8065b823a11f860cf3a1901535852811 Mon Sep 17 00:00:00 2001 From: MattScarthSaunders Date: Tue, 21 Oct 2025 16:10:13 +0100 Subject: [PATCH 1/4] feat: trial client service --- package-lock.json | 147 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/main/index.ts | 1 + src/main/trial.ts | 115 ++++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 src/main/trial.ts diff --git a/package-lock.json b/package-lock.json index 224c277..d709b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "ajv-formats": "^2.0.2", "commander": "^7.2.0", "dotenv": "^16.3.1", + "ioredis": "^5.8.1", "jsonwebtoken": "^9.0.0", "koa": "^2.11.0", "koa-body": "^4.2.0", @@ -467,6 +468,12 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -1716,6 +1723,15 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -1948,6 +1964,15 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5204,6 +5229,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.1.tgz", + "integrity": "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -5781,12 +5830,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -7031,6 +7092,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -7442,6 +7524,12 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -8606,6 +8694,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==" + }, "@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -9586,6 +9679,11 @@ "wrap-ansi": "^7.0.0" } }, + "cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -9758,6 +9856,11 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -12239,6 +12342,22 @@ "side-channel": "^1.0.4" } }, + "ioredis": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.1.tgz", + "integrity": "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==", + "requires": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + } + }, "ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -12675,11 +12794,21 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -13603,6 +13732,19 @@ "picomatch": "^2.2.1" } }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "requires": { + "redis-errors": "^1.0.0" + } + }, "reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -13906,6 +14048,11 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", diff --git a/package.json b/package.json index f56557a..74b37a8 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "ajv-formats": "^2.0.2", "commander": "^7.2.0", "dotenv": "^16.3.1", + "ioredis": "^5.8.1", "jsonwebtoken": "^9.0.0", "koa": "^2.11.0", "koa-body": "^4.2.0", diff --git a/src/main/index.ts b/src/main/index.ts index b98e978..ac2c9a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ export * from './openapi.js'; export * from './router.js'; export * from './schema.js'; export * from './services/index.js'; +export * from './trial.js'; export * from './util.js'; export * from '@nodescript/logger'; diff --git a/src/main/trial.ts b/src/main/trial.ts new file mode 100644 index 0000000..d4ac924 --- /dev/null +++ b/src/main/trial.ts @@ -0,0 +1,115 @@ +import { Logger } from '@nodescript/logger'; +import { Redis } from 'ioredis'; +import { config } from 'mesh-config'; +import { dep } from 'mesh-ioc'; + +import { AccessForbidden } from './ac-auth.js'; + +export interface TrialServiceRestriction { + serviceName: string; + requestCount: number; +} + +export interface Trial { + serviceRestrictions: Array; +} + +export interface TokenServiceRestriction { + serviceName: string; + requestLimit: number; +} + +export class TrialClient { + + @config() private REDIS_URL!: string; + @config({ default: 'cache:framework:trialClient' }) private TRIAL_KEY_PREFIX!: string; + @dep() private logger!: Logger; + + private isRunning = false; + redisClient: Redis; + + constructor() { + this.redisClient = this.createRedisClient(); + } + + async start() { + if (this.isRunning) { + return; + } + this.isRunning = true; + await this.redisClient.connect(); + this.logger.info('TrialClient Redis connected'); + } + + async stop() { + try { + this.redisClient.disconnect(); + this.logger.info('TrialClient Redis disconnected'); + } finally { + this.isRunning = false; + } + } + + private createRedisClient() { + return new Redis(this.REDIS_URL, { + lazyConnect: true, + disconnectTimeout: 10, + }); + } + + isTrialToken(token: Record) { + return !!token.serviceRestrictions; + } + + async assertTrial(token: Record) { + const trial = await this.read(token.clientId); + if (!trial) { + throw new AccessForbidden('Trial access for token not configured'); + } + return trial; + } + + async requireServiceRestriction(trial: Trial, token: Record, serviceName: string) { + const serviceRestriction = trial.serviceRestrictions.find((service: TrialServiceRestriction) => service.serviceName === serviceName); + const tokenServiceRestriction = token.serviceRestrictions.find((service: TokenServiceRestriction) => service.serviceName === serviceName); + + if (!serviceRestriction || !tokenServiceRestriction) { + throw new AccessForbidden('Service access for token not configured'); + } + if (serviceRestriction.requestCount > tokenServiceRestriction.requestLimit) { + throw new AccessForbidden('Trial token has exceeded request limit for service'); + } + return serviceRestriction; + } + + async incrementRequests(token: Record, serviceName: string) { + const trial = await this.assertTrial(token); + const serviceRestriction = await this.requireServiceRestriction(trial, token, serviceName); + serviceRestriction.requestCount++; + await this.update(token.clientId, trial); + } + + async create(key: string, value: Trial, expirySeconds: number) { + const existing = await this.read(key); + if (existing) { + throw new Error('Trial data already exists for key'); + } + await this.redisClient.setex(`${this.TRIAL_KEY_PREFIX}:${key}`, expirySeconds, JSON.stringify(value)); + } + + async read(key: string): Promise { + const existing = await this.redisClient.get(`${this.TRIAL_KEY_PREFIX}:${key}`); + if (existing) { + return JSON.parse(existing); + } + return null; + } + + async update(key: string, value: Trial) { + await this.redisClient.set(`${this.TRIAL_KEY_PREFIX}:${key}`, JSON.stringify(value), 'KEEPTTL'); + } + + async delete(key: string): Promise { + await this.redisClient.del(`${this.TRIAL_KEY_PREFIX}:${key}`); + } +} From 3eb884ff8324c4734e5582aceeae73a6dd18c194 Mon Sep 17 00:00:00 2001 From: MattScarthSaunders Date: Tue, 21 Oct 2025 17:00:00 +0100 Subject: [PATCH 2/4] refactor: suggestions, clarity --- src/main/trial.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/trial.ts b/src/main/trial.ts index d4ac924..f98efb2 100644 --- a/src/main/trial.ts +++ b/src/main/trial.ts @@ -22,10 +22,11 @@ export interface TokenServiceRestriction { export class TrialClient { @config() private REDIS_URL!: string; - @config({ default: 'cache:framework:trialClient' }) private TRIAL_KEY_PREFIX!: string; @dep() private logger!: Logger; private isRunning = false; + private trialKeyPrefix = 'cache:framework:trialClient'; + redisClient: Redis; constructor() { @@ -69,7 +70,7 @@ export class TrialClient { return trial; } - async requireServiceRestriction(trial: Trial, token: Record, serviceName: string) { + async requireValidServiceRestriction(trial: Trial, token: Record, serviceName: string) { const serviceRestriction = trial.serviceRestrictions.find((service: TrialServiceRestriction) => service.serviceName === serviceName); const tokenServiceRestriction = token.serviceRestrictions.find((service: TokenServiceRestriction) => service.serviceName === serviceName); @@ -79,13 +80,13 @@ export class TrialClient { if (serviceRestriction.requestCount > tokenServiceRestriction.requestLimit) { throw new AccessForbidden('Trial token has exceeded request limit for service'); } - return serviceRestriction; } async incrementRequests(token: Record, serviceName: string) { const trial = await this.assertTrial(token); - const serviceRestriction = await this.requireServiceRestriction(trial, token, serviceName); - serviceRestriction.requestCount++; + await this.requireValidServiceRestriction(trial, token, serviceName); + const index = trial.serviceRestrictions.findIndex((service: TrialServiceRestriction) => service.serviceName === serviceName); + trial.serviceRestrictions[index].requestCount++; await this.update(token.clientId, trial); } @@ -94,11 +95,11 @@ export class TrialClient { if (existing) { throw new Error('Trial data already exists for key'); } - await this.redisClient.setex(`${this.TRIAL_KEY_PREFIX}:${key}`, expirySeconds, JSON.stringify(value)); + await this.redisClient.setex(`${this.trialKeyPrefix}:${key}`, expirySeconds, JSON.stringify(value)); } async read(key: string): Promise { - const existing = await this.redisClient.get(`${this.TRIAL_KEY_PREFIX}:${key}`); + const existing = await this.redisClient.get(`${this.trialKeyPrefix}:${key}`); if (existing) { return JSON.parse(existing); } @@ -106,10 +107,10 @@ export class TrialClient { } async update(key: string, value: Trial) { - await this.redisClient.set(`${this.TRIAL_KEY_PREFIX}:${key}`, JSON.stringify(value), 'KEEPTTL'); + await this.redisClient.set(`${this.trialKeyPrefix}:${key}`, JSON.stringify(value), 'KEEPTTL'); } async delete(key: string): Promise { - await this.redisClient.del(`${this.TRIAL_KEY_PREFIX}:${key}`); + await this.redisClient.del(`${this.trialKeyPrefix}:${key}`); } } From 2e51c164f079580e6aa59364eb8564375c8a77a4 Mon Sep 17 00:00:00 2001 From: MattScarthSaunders Date: Wed, 22 Oct 2025 11:47:47 +0100 Subject: [PATCH 3/4] refactor: simplify and use hincrby --- src/main/trial.ts | 74 ++++++++++++++--------------------------------- 1 file changed, 21 insertions(+), 53 deletions(-) diff --git a/src/main/trial.ts b/src/main/trial.ts index f98efb2..25e0176 100644 --- a/src/main/trial.ts +++ b/src/main/trial.ts @@ -5,15 +5,6 @@ import { dep } from 'mesh-ioc'; import { AccessForbidden } from './ac-auth.js'; -export interface TrialServiceRestriction { - serviceName: string; - requestCount: number; -} - -export interface Trial { - serviceRestrictions: Array; -} - export interface TokenServiceRestriction { serviceName: string; requestLimit: number; @@ -51,66 +42,43 @@ export class TrialClient { } } - private createRedisClient() { - return new Redis(this.REDIS_URL, { - lazyConnect: true, - disconnectTimeout: 10, - }); - } - isTrialToken(token: Record) { return !!token.serviceRestrictions; } - async assertTrial(token: Record) { - const trial = await this.read(token.clientId); - if (!trial) { - throw new AccessForbidden('Trial access for token not configured'); - } - return trial; - } - - async requireValidServiceRestriction(trial: Trial, token: Record, serviceName: string) { - const serviceRestriction = trial.serviceRestrictions.find((service: TrialServiceRestriction) => service.serviceName === serviceName); - const tokenServiceRestriction = token.serviceRestrictions.find((service: TokenServiceRestriction) => service.serviceName === serviceName); - - if (!serviceRestriction || !tokenServiceRestriction) { - throw new AccessForbidden('Service access for token not configured'); + async requireValidServiceRestriction(token: Record, serviceName: string) { + const serviceRestriction = token.serviceRestrictions.find((s: TokenServiceRestriction) => s.serviceName === serviceName); + if (!serviceRestriction) { + throw new AccessForbidden('Service access not configured on token'); } - if (serviceRestriction.requestCount > tokenServiceRestriction.requestLimit) { + const requestCount = await this.getRequestCount(token.clientId, serviceName); + if (requestCount >= serviceRestriction.requestLimit) { throw new AccessForbidden('Trial token has exceeded request limit for service'); } } async incrementRequests(token: Record, serviceName: string) { - const trial = await this.assertTrial(token); - await this.requireValidServiceRestriction(trial, token, serviceName); - const index = trial.serviceRestrictions.findIndex((service: TrialServiceRestriction) => service.serviceName === serviceName); - trial.serviceRestrictions[index].requestCount++; - await this.update(token.clientId, trial); - } - - async create(key: string, value: Trial, expirySeconds: number) { - const existing = await this.read(key); - if (existing) { - throw new Error('Trial data already exists for key'); - } - await this.redisClient.setex(`${this.trialKeyPrefix}:${key}`, expirySeconds, JSON.stringify(value)); + const redisKey = this.getServiceKey(token.clientId, serviceName); + await this.redisClient.hincrby(redisKey, 'requestCount', 1); } - async read(key: string): Promise { - const existing = await this.redisClient.get(`${this.trialKeyPrefix}:${key}`); - if (existing) { - return JSON.parse(existing); + async getRequestCount(clientId: string, serviceName: string) { + const redisKey = this.getServiceKey(clientId, serviceName); + const requestCountStr = await this.redisClient.hget(redisKey, 'requestCount'); + if (requestCountStr == null) { + throw new AccessForbidden('Service access for token not configured'); } - return null; + return Number(requestCountStr); } - async update(key: string, value: Trial) { - await this.redisClient.set(`${this.trialKeyPrefix}:${key}`, JSON.stringify(value), 'KEEPTTL'); + private createRedisClient() { + return new Redis(this.REDIS_URL, { + lazyConnect: true, + disconnectTimeout: 10, + }); } - async delete(key: string): Promise { - await this.redisClient.del(`${this.trialKeyPrefix}:${key}`); + private getServiceKey(clientId: string, serviceName: string) { + return `${this.trialKeyPrefix}:${clientId}:${serviceName}`; } } From df7f1215ae41c4f8aab785424e378938248cfd3e Mon Sep 17 00:00:00 2001 From: MattScarthSaunders Date: Thu, 23 Oct 2025 14:14:28 +0100 Subject: [PATCH 4/4] chore: tighter typing, simplify key --- src/main/trial.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/trial.ts b/src/main/trial.ts index 25e0176..201d542 100644 --- a/src/main/trial.ts +++ b/src/main/trial.ts @@ -10,13 +10,18 @@ export interface TokenServiceRestriction { requestLimit: number; } +export interface TrialToken { + serviceRestrictions: Array; + [key: string]: any; +} + export class TrialClient { @config() private REDIS_URL!: string; @dep() private logger!: Logger; private isRunning = false; - private trialKeyPrefix = 'cache:framework:trialClient'; + private trialKeyPrefix = 'cache:trialClient'; redisClient: Redis; @@ -42,11 +47,11 @@ export class TrialClient { } } - isTrialToken(token: Record) { + isTrialToken(token: Record): token is TrialToken { return !!token.serviceRestrictions; } - async requireValidServiceRestriction(token: Record, serviceName: string) { + async requireValidServiceRestriction(token: TrialToken, serviceName: string) { const serviceRestriction = token.serviceRestrictions.find((s: TokenServiceRestriction) => s.serviceName === serviceName); if (!serviceRestriction) { throw new AccessForbidden('Service access not configured on token');