@usageflow/core 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,290 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { UsageFlowAPI } from '../src/base';
4
+ import { Route, UsageFlowRequest, RequestMetadata, RequestForAllocation } from '../src/types';
5
+
6
+ // Mock socket manager
7
+ class MockSocketManager {
8
+ connected = false;
9
+ async connect() {
10
+ this.connected = true;
11
+ }
12
+ isConnected() {
13
+ return this.connected;
14
+ }
15
+ async sendAsync<T>(payload: any): Promise<any> {
16
+ if (payload.type === 'get_application_policies') {
17
+ return {
18
+ type: 'success',
19
+ payload: { policies: [], total: 0 }
20
+ };
21
+ }
22
+ return { type: 'success', payload: {} };
23
+ }
24
+ send(payload: any) { }
25
+ close() { }
26
+ destroy() { }
27
+ }
28
+
29
+ // Create a concrete implementation for testing
30
+ class TestUsageFlowAPI extends UsageFlowAPI {
31
+ constructor(config: any = { apiKey: 'test-key', poolSize: 1 }) {
32
+ super(config);
33
+ // Replace socket manager with mock
34
+ this.socketManager = new MockSocketManager() as any;
35
+ }
36
+ }
37
+
38
+ describe('UsageFlowAPI', () => {
39
+ let api: TestUsageFlowAPI;
40
+
41
+ beforeEach(() => {
42
+ api = new TestUsageFlowAPI();
43
+ });
44
+
45
+ afterEach(() => {
46
+ api.destroy();
47
+ });
48
+
49
+ test('should initialize with API key', () => {
50
+ assert.strictEqual(api.getApiKey(), 'test-key');
51
+ });
52
+
53
+ test('should get usageflow URL', () => {
54
+ assert.strictEqual(api.getUsageflowUrl(), 'https://api.usageflow.io');
55
+ });
56
+
57
+ test('should set web server type', () => {
58
+ api.setWebServer('fastify');
59
+ // Verify by checking route pattern behavior
60
+ const request: UsageFlowRequest = {
61
+ method: 'GET',
62
+ url: '/test',
63
+ routeOptions: { url: '/test' },
64
+ headers: {}
65
+ };
66
+ const pattern = api.getRoutePattern(request);
67
+ assert.strictEqual(pattern, '/test');
68
+ });
69
+
70
+ test('should create routes map', () => {
71
+ const routes: Route[] = [
72
+ { method: 'GET', url: '/users' },
73
+ { method: 'POST', url: '/users' },
74
+ { method: 'GET', url: '/posts' }
75
+ ];
76
+ const routesMap = api.createRoutesMap(routes);
77
+
78
+ assert.strictEqual(routesMap['GET']['/users'], true);
79
+ assert.strictEqual(routesMap['POST']['/users'], true);
80
+ assert.strictEqual(routesMap['GET']['/posts'], true);
81
+ });
82
+
83
+ test('should check if route should be monitored', () => {
84
+ const routes: Route[] = [
85
+ { method: 'GET', url: '/users' },
86
+ { method: '*', url: '*' }
87
+ ];
88
+ const routesMap = api.createRoutesMap(routes);
89
+
90
+ assert.strictEqual(api.shouldMonitorRoute('GET', '/users', routesMap), true);
91
+ assert.strictEqual(api.shouldMonitorRoute('POST', '/posts', routesMap), true); // wildcard
92
+ assert.strictEqual(api.shouldMonitorRoute('GET', '/unknown', routesMap), true); // wildcard
93
+ });
94
+
95
+ test('should check if route should be skipped', () => {
96
+ const whitelistRoutes: Route[] = [
97
+ { method: 'GET', url: '/health' },
98
+ { method: '*', url: '/metrics' }
99
+ ];
100
+ const whitelistMap = api.createRoutesMap(whitelistRoutes);
101
+
102
+ assert.strictEqual(api.shouldSkipRoute('GET', '/health', whitelistMap), true);
103
+ assert.strictEqual(api.shouldSkipRoute('POST', '/metrics', whitelistMap), true);
104
+ assert.strictEqual(api.shouldSkipRoute('GET', '/users', whitelistMap), false);
105
+ });
106
+
107
+ test('should sanitize headers', () => {
108
+ const headers = {
109
+ 'authorization': 'Bearer token123',
110
+ 'x-api-key': 'secret-key',
111
+ 'content-type': 'application/json',
112
+ 'user-agent': 'test-agent'
113
+ };
114
+
115
+ const sanitized = api.sanitizeHeaders(headers);
116
+
117
+ assert.strictEqual(sanitized['authorization'], 'Bearer ****');
118
+ assert.strictEqual(sanitized['x-api-key'], '****');
119
+ assert.strictEqual(sanitized['content-type'], 'application/json');
120
+ assert.strictEqual(sanitized['user-agent'], 'test-agent');
121
+ });
122
+
123
+ test('should extract bearer token', () => {
124
+ assert.strictEqual(api.extractBearerToken('Bearer token123'), 'token123');
125
+ assert.strictEqual(api.extractBearerToken('bearer token123'), 'token123');
126
+ assert.strictEqual(api.extractBearerToken('Invalid token'), null);
127
+ assert.strictEqual(api.extractBearerToken(undefined), null);
128
+ });
129
+
130
+ test('should decode JWT unverified', () => {
131
+ // Create a simple JWT (header.payload.signature)
132
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64');
133
+ const payload = Buffer.from(JSON.stringify({ userId: '123', name: 'Test' })).toString('base64');
134
+ const token = `${header}.${payload}.signature`;
135
+
136
+ const decoded = api.decodeJwtUnverified(token);
137
+
138
+ assert.strictEqual(decoded?.userId, '123');
139
+ assert.strictEqual(decoded?.name, 'Test');
140
+ });
141
+
142
+ test('should return null for invalid JWT', () => {
143
+ assert.strictEqual(api.decodeJwtUnverified('invalid'), null);
144
+ assert.strictEqual(api.decodeJwtUnverified('header.payload'), null);
145
+ });
146
+
147
+ test('should get route pattern for Fastify', () => {
148
+ api.setWebServer('fastify');
149
+ const request: UsageFlowRequest = {
150
+ method: 'GET',
151
+ url: '/test',
152
+ routeOptions: { url: '/test/:id' },
153
+ headers: {}
154
+ };
155
+ const pattern = api.getRoutePattern(request);
156
+ assert.strictEqual(pattern, '/test/:id');
157
+ });
158
+
159
+ test('should get route pattern for Express', () => {
160
+ api.setWebServer('express');
161
+ const request: UsageFlowRequest = {
162
+ method: 'GET',
163
+ url: '/test',
164
+ path: '/test',
165
+ route: { path: '/test/:id' },
166
+ headers: {},
167
+ app: {} as any
168
+ };
169
+ const pattern = api.getRoutePattern(request);
170
+ assert.strictEqual(pattern, '/test/:id');
171
+ });
172
+
173
+ test('should guess ledger ID from path params', () => {
174
+ (api as any).apiConfigs = [{
175
+ url: '/users/:id',
176
+ method: 'GET',
177
+ identityFieldName: 'id',
178
+ identityFieldLocation: 'path_params'
179
+ }];
180
+
181
+ const request: UsageFlowRequest = {
182
+ method: 'GET',
183
+ url: '/users',
184
+ path: '/users/123',
185
+ params: { id: '123' },
186
+ headers: {}
187
+ };
188
+
189
+ const ledgerId = api.guessLedgerId(request);
190
+ assert.strictEqual(ledgerId, 'GET /users/123 123');
191
+ });
192
+
193
+ test('should guess ledger ID from query params', () => {
194
+ (api as any).apiConfigs = [{
195
+ url: '/users',
196
+ method: 'GET',
197
+ identityFieldName: 'userId',
198
+ identityFieldLocation: 'query_params'
199
+ }];
200
+
201
+ const request: UsageFlowRequest = {
202
+ method: 'GET',
203
+ url: '/users',
204
+ query: { userId: '456' },
205
+ headers: {}
206
+ };
207
+
208
+ const ledgerId = api.guessLedgerId(request);
209
+ assert.strictEqual(ledgerId, 'GET /users 456');
210
+ });
211
+
212
+ test('should guess ledger ID from body', () => {
213
+ (api as any).apiConfigs = [{
214
+ url: '/users',
215
+ method: 'POST',
216
+ identityFieldName: 'userId',
217
+ identityFieldLocation: 'body'
218
+ }];
219
+
220
+ const request: UsageFlowRequest = {
221
+ method: 'POST',
222
+ url: '/users',
223
+ body: { userId: '789' },
224
+ headers: {}
225
+ };
226
+
227
+ const ledgerId = api.guessLedgerId(request);
228
+ assert.strictEqual(ledgerId, 'POST /users 789');
229
+ });
230
+
231
+ test('should guess ledger ID from bearer token', () => {
232
+ (api as any).apiConfigs = [{
233
+ url: '/users',
234
+ method: 'GET',
235
+ identityFieldName: 'userId',
236
+ identityFieldLocation: 'bearer_token'
237
+ }];
238
+
239
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256' })).toString('base64');
240
+ const payload = Buffer.from(JSON.stringify({ userId: 'token-user' })).toString('base64');
241
+ const token = `${header}.${payload}.signature`;
242
+
243
+ const request: UsageFlowRequest = {
244
+ method: 'GET',
245
+ url: '/users',
246
+ headers: { authorization: `Bearer ${token}` }
247
+ };
248
+
249
+ const ledgerId = api.guessLedgerId(request);
250
+ assert.strictEqual(ledgerId, 'GET /users token-user');
251
+ });
252
+
253
+ test('should guess ledger ID from headers', () => {
254
+ (api as any).apiConfigs = [{
255
+ url: '/users',
256
+ method: 'GET',
257
+ identityFieldName: 'x-user-id',
258
+ identityFieldLocation: 'headers'
259
+ }];
260
+
261
+ const request: UsageFlowRequest = {
262
+ method: 'GET',
263
+ url: '/users',
264
+ headers: { 'x-user-id': 'header-user' }
265
+ };
266
+
267
+ const ledgerId = api.guessLedgerId(request);
268
+ assert.strictEqual(ledgerId, 'GET /users header-user');
269
+ });
270
+
271
+ test('should return default ledger ID when no config matches', () => {
272
+ (api as any).apiConfigs = [];
273
+
274
+ const request: UsageFlowRequest = {
275
+ method: 'GET',
276
+ url: '/users',
277
+ headers: {}
278
+ };
279
+
280
+ const ledgerId = api.guessLedgerId(request);
281
+ assert.strictEqual(ledgerId, 'GET /users');
282
+ });
283
+
284
+ test('should destroy and clean up resources', () => {
285
+ api.destroy();
286
+ // Should not throw
287
+ assert.doesNotThrow(() => api.destroy());
288
+ });
289
+ });
290
+
@@ -0,0 +1,72 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { UsageFlowSocketManger } from '../src/socket';
4
+ import { UsageFlowSocketMessage } from '../src/types';
5
+
6
+ describe('UsageFlowSocketManger', () => {
7
+ let socketManager: UsageFlowSocketManger = null as any;
8
+
9
+ beforeEach(() => {
10
+ socketManager = new UsageFlowSocketManger('test-api-key', 1);
11
+ });
12
+
13
+ afterEach(() => {
14
+ socketManager.destroy();
15
+ });
16
+
17
+ test('should initialize with API key', () => {
18
+ assert.ok(socketManager);
19
+ });
20
+
21
+ test('should check connection status', () => {
22
+ // Initially not connected
23
+ assert.strictEqual(socketManager.isConnected(), false);
24
+ });
25
+
26
+ test('should handle connection failure gracefully', async () => {
27
+ // This will fail because we don't have a real WebSocket server
28
+ // But it should not throw
29
+ try {
30
+ await socketManager.connect();
31
+ } catch (error) {
32
+ // Expected to fail without real server
33
+ }
34
+ // Should handle gracefully
35
+ assert.ok(true);
36
+ });
37
+
38
+ test('should close connections', () => {
39
+ // Should not throw
40
+ assert.doesNotThrow(() => socketManager.close());
41
+ });
42
+
43
+ test('should destroy and clean up', () => {
44
+ // Should not throw
45
+ assert.doesNotThrow(() => socketManager.destroy());
46
+ });
47
+
48
+ test('should handle send when not connected', () => {
49
+ const message: UsageFlowSocketMessage = {
50
+ type: 'get_application_policies',
51
+ payload: null
52
+ };
53
+
54
+ // Should throw when not connected
55
+ assert.throws(() => {
56
+ socketManager.send(message);
57
+ }, /WebSocket not connected/);
58
+ });
59
+
60
+ test('should handle sendAsync when not connected', async () => {
61
+ const message: UsageFlowSocketMessage = {
62
+ type: 'get_application_policies',
63
+ payload: null
64
+ };
65
+
66
+ // Should throw when not connected
67
+ await assert.rejects(async () => {
68
+ await socketManager.sendAsync(message);
69
+ }, /WebSocket not connected/);
70
+ });
71
+ });
72
+
@@ -0,0 +1,62 @@
1
+ import { Route, UsageFlowConfig, RoutesMap, Headers, RequestForAllocation, RequestMetadata, UsageFlowRequest, UsageFlowAPIConfig } from "./types";
2
+ import { UsageFlowSocketManger } from "./socket";
3
+ export declare abstract class UsageFlowAPI {
4
+ protected apiKey: string | null;
5
+ protected usageflowUrl: string;
6
+ protected webServer: 'express' | 'fastify' | 'nestjs';
7
+ protected apiConfigs: UsageFlowConfig[];
8
+ private configUpdateInterval;
9
+ socketManager: UsageFlowSocketManger | null;
10
+ private applicationId;
11
+ constructor(config?: UsageFlowAPIConfig);
12
+ /**
13
+ * Initialize the UsageFlow API with credentials
14
+ * @param apiKey - Your UsageFlow API key
15
+ * @param usageflowUrl - The UsageFlow API URL (default: https://api.usageflow.io)
16
+ * @param poolSize - Number of WebSocket connections in the pool (default: 5)
17
+ */
18
+ private init;
19
+ setWebServer(webServer: 'express' | 'fastify' | 'nestjs'): void;
20
+ getApiKey(): string | null;
21
+ getUsageflowUrl(): string;
22
+ /**
23
+ * Start background config update process
24
+ */
25
+ private startConfigUpdater;
26
+ getRoutePattern(request: UsageFlowRequest): string;
27
+ guessLedgerId(request: UsageFlowRequest, overrideUrl?: string): string;
28
+ private fetchApiPolicies;
29
+ useAllocationRequest(payload: RequestForAllocation): Promise<void>;
30
+ allocationRequest(request: UsageFlowRequest, payload: RequestForAllocation, metadata: RequestMetadata): Promise<void>;
31
+ /**
32
+ * Convert routes list to a map for faster lookup
33
+ */
34
+ createRoutesMap(routes: Route[]): RoutesMap;
35
+ /**
36
+ * Check if the route should be skipped based on whitelist
37
+ */
38
+ shouldSkipRoute(method: string, url: string, whitelistMap: RoutesMap): boolean;
39
+ /**
40
+ * Check if the route should be monitored
41
+ */
42
+ shouldMonitorRoute(method: string, url: string, routesMap: RoutesMap): boolean;
43
+ /**
44
+ * Sanitize sensitive information from headers
45
+ */
46
+ sanitizeHeaders(headers: Headers): Headers;
47
+ /**
48
+ * Clean up resources when shutting down
49
+ */
50
+ destroy(): void;
51
+ extractBearerToken(authHeader?: string): string | null;
52
+ /**
53
+ * Get header value from headers object (handles both Record and Headers types)
54
+ */
55
+ private getHeaderValue;
56
+ /**
57
+ * Decodes a JWT token without verifying the signature
58
+ * @param token The JWT token to decode
59
+ * @returns The decoded claims or null if invalid
60
+ */
61
+ decodeJwtUnverified(token: string): Record<string, any> | null;
62
+ }