@usageflow/core 0.2.5 → 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.
- package/README.md +207 -26
- package/dist/base.d.ts +4 -4
- package/dist/base.js +75 -45
- package/dist/base.js.map +1 -1
- package/jest.config.js +14 -0
- package/package.json +6 -7
- package/src/base.ts +139 -71
- package/test/base.test.ts +290 -0
- package/test/socket.test.ts +72 -0
- package/test/src/base.d.ts +62 -0
- package/test/src/base.js +440 -0
- package/test/src/base.js.map +1 -0
- package/test/src/index.d.ts +3 -0
- package/test/src/index.js +20 -0
- package/test/src/index.js.map +1 -0
- package/test/src/socket.d.ts +26 -0
- package/test/src/socket.js +266 -0
- package/test/src/socket.js.map +1 -0
- package/test/src/types.d.ts +117 -0
- package/test/src/types.js +11 -0
- package/test/src/types.js.map +1 -0
- package/test/tsconfig.test.tsbuildinfo +1 -0
- package/test/types.test.ts +56 -0
- package/tsconfig.json +2 -1
- package/tsconfig.test.json +11 -0
- package/tsconfig.test.tsbuildinfo +1 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|