keypointjs 1.0.0

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,351 @@
1
+ import { WebSocketServer } from 'ws';
2
+
3
+ export class WebSocketGuard {
4
+ constructor(options = {}) {
5
+ this.options = {
6
+ path: '/ws',
7
+ verifyClient: this.defaultVerifyClient.bind(this),
8
+ keypointHeader: 'x-keypoint-id',
9
+ requireKeypoint: true,
10
+ pingInterval: 30000,
11
+ maxConnections: 1000,
12
+ ...options
13
+ };
14
+
15
+ this.wss = null;
16
+ this.connections = new Map();
17
+ this.messageHandlers = new Map();
18
+ this.connectionCallbacks = [];
19
+ this.disconnectionCallbacks = [];
20
+ }
21
+
22
+ async process(context, next) {
23
+ // This plugin works differently - it creates WebSocket server
24
+ // For HTTP requests, just pass through
25
+ return next(context);
26
+ }
27
+
28
+ attachToServer(httpServer, keypointJS) {
29
+ this.wss = new WebSocketServer({
30
+ server: httpServer,
31
+ path: this.options.path,
32
+ verifyClient: (info, callback) => {
33
+ this.options.verifyClient(info, callback, keypointJS);
34
+ }
35
+ });
36
+
37
+ this.setupWebSocketHandlers(keypointJS);
38
+ return this;
39
+ }
40
+
41
+ defaultVerifyClient(info, callback, keypointJS) {
42
+ const req = info.req;
43
+ const keypointId = req.headers[this.options.keypointHeader];
44
+
45
+ if (this.options.requireKeypoint && !keypointId) {
46
+ callback(false, 401, 'Keypoint ID required');
47
+ return;
48
+ }
49
+
50
+ // Create a mock context for validation
51
+ const mockContext = {
52
+ request: {
53
+ headers: req.headers,
54
+ url: req.url,
55
+ method: 'GET',
56
+ ip: req.socket.remoteAddress
57
+ },
58
+ getKeypointId: () => keypointId
59
+ };
60
+
61
+ // Validate keypoint if provided
62
+ if (keypointId) {
63
+ keypointJS.keypointValidator.validate(mockContext)
64
+ .then(() => {
65
+ callback(true);
66
+ })
67
+ .catch(() => {
68
+ callback(false, 401, 'Invalid keypoint');
69
+ });
70
+ } else {
71
+ callback(true);
72
+ }
73
+ }
74
+
75
+ setupWebSocketHandlers(keypointJS) {
76
+ this.wss.on('connection', (ws, req) => {
77
+ const connectionId = this.generateConnectionId();
78
+ const keypointId = req.headers[this.options.keypointHeader];
79
+
80
+ const connection = {
81
+ id: connectionId,
82
+ ws,
83
+ req,
84
+ keypointId,
85
+ ip: req.socket.remoteAddress,
86
+ connectedAt: new Date(),
87
+ lastActivity: new Date(),
88
+ metadata: {}
89
+ };
90
+
91
+ this.connections.set(connectionId, connection);
92
+
93
+ // Attach keypoint to WebSocket
94
+ if (keypointId) {
95
+ keypointJS.keypointStorage.get(keypointId)
96
+ .then(keypoint => {
97
+ connection.keypoint = keypoint;
98
+ connection.scopes = keypoint.scopes;
99
+ })
100
+ .catch(() => {
101
+ // Keypoint not found, but connection already established
102
+ });
103
+ }
104
+
105
+ // Setup message handler
106
+ ws.on('message', async (data) => {
107
+ connection.lastActivity = new Date();
108
+ await this.handleMessage(connection, data);
109
+ });
110
+
111
+ // Setup ping/pong
112
+ ws.on('pong', () => {
113
+ connection.lastActivity = new Date();
114
+ });
115
+
116
+ // Handle close
117
+ ws.on('close', () => {
118
+ this.handleDisconnection(connectionId);
119
+ });
120
+
121
+ // Handle errors
122
+ ws.on('error', (error) => {
123
+ console.error(`WebSocket error for connection ${connectionId}:`, error);
124
+ this.handleDisconnection(connectionId);
125
+ });
126
+
127
+ // Send welcome message
128
+ ws.send(JSON.stringify({
129
+ type: 'welcome',
130
+ connectionId,
131
+ timestamp: new Date().toISOString()
132
+ }));
133
+
134
+ // Call connection callbacks
135
+ this.connectionCallbacks.forEach(callback => callback(connection));
136
+ });
137
+
138
+ // Setup ping interval
139
+ setInterval(() => {
140
+ this.checkConnections();
141
+ }, this.options.pingInterval);
142
+ }
143
+
144
+ generateConnectionId() {
145
+ return Math.random().toString(36).substring(2) +
146
+ Date.now().toString(36);
147
+ }
148
+
149
+ async handleMessage(connection, data) {
150
+ let message;
151
+
152
+ try {
153
+ message = JSON.parse(data.toString());
154
+ } catch {
155
+ connection.ws.send(JSON.stringify({
156
+ type: 'error',
157
+ error: 'Invalid JSON message'
158
+ }));
159
+ return;
160
+ }
161
+
162
+ // Check if handler exists for message type
163
+ const handler = this.messageHandlers.get(message.type);
164
+ if (handler) {
165
+ try {
166
+ const result = await handler(message, connection);
167
+ if (result) {
168
+ connection.ws.send(JSON.stringify(result));
169
+ }
170
+ } catch (error) {
171
+ connection.ws.send(JSON.stringify({
172
+ type: 'error',
173
+ error: error.message
174
+ }));
175
+ }
176
+ } else {
177
+ // Default echo handler
178
+ connection.ws.send(JSON.stringify({
179
+ type: 'echo',
180
+ timestamp: new Date().toISOString(),
181
+ data: message
182
+ }));
183
+ }
184
+ }
185
+
186
+ handleDisconnection(connectionId) {
187
+ const connection = this.connections.get(connectionId);
188
+ if (connection) {
189
+ this.connections.delete(connectionId);
190
+
191
+ // Call disconnection callbacks
192
+ this.disconnectionCallbacks.forEach(callback => callback(connection));
193
+ }
194
+ }
195
+
196
+ checkConnections() {
197
+ const now = new Date();
198
+ const timeout = this.options.pingInterval * 2;
199
+
200
+ for (const [connectionId, connection] of this.connections) {
201
+ const idleTime = now - connection.lastActivity;
202
+
203
+ if (idleTime > timeout) {
204
+ connection.ws.terminate();
205
+ this.connections.delete(connectionId);
206
+ } else {
207
+ // Send ping
208
+ connection.ws.ping();
209
+ }
210
+ }
211
+ }
212
+
213
+ // Public API
214
+ onConnection(callback) {
215
+ this.connectionCallbacks.push(callback);
216
+ return this;
217
+ }
218
+
219
+ onDisconnection(callback) {
220
+ this.disconnectionCallbacks.push(callback);
221
+ return this;
222
+ }
223
+
224
+ onMessage(type, handler) {
225
+ this.messageHandlers.set(type, handler);
226
+ return this;
227
+ }
228
+
229
+ broadcast(message, filter = {}) {
230
+ const messageStr = JSON.stringify(message);
231
+
232
+ for (const connection of this.connections.values()) {
233
+ // Apply filters
234
+ if (filter.keypointId && connection.keypointId !== filter.keypointId) {
235
+ continue;
236
+ }
237
+
238
+ if (filter.scope && (!connection.scopes || !connection.scopes.includes(filter.scope))) {
239
+ continue;
240
+ }
241
+
242
+ if (filter.ip && connection.ip !== filter.ip) {
243
+ continue;
244
+ }
245
+
246
+ connection.ws.send(messageStr);
247
+ }
248
+
249
+ return this.connections.size;
250
+ }
251
+
252
+ sendToConnection(connectionId, message) {
253
+ const connection = this.connections.get(connectionId);
254
+ if (connection) {
255
+ connection.ws.send(JSON.stringify(message));
256
+ return true;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ getConnection(connectionId) {
262
+ return this.connections.get(connectionId);
263
+ }
264
+
265
+ getConnections(filter = {}) {
266
+ let connections = Array.from(this.connections.values());
267
+
268
+ if (filter.keypointId) {
269
+ connections = connections.filter(c => c.keypointId === filter.keypointId);
270
+ }
271
+
272
+ if (filter.scope) {
273
+ connections = connections.filter(c =>
274
+ c.scopes && c.scopes.includes(filter.scope)
275
+ );
276
+ }
277
+
278
+ if (filter.ip) {
279
+ connections = connections.filter(c => c.ip === filter.ip);
280
+ }
281
+
282
+ return connections;
283
+ }
284
+
285
+ disconnect(connectionId) {
286
+ const connection = this.connections.get(connectionId);
287
+ if (connection) {
288
+ connection.ws.close();
289
+ return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ disconnectAll(filter = {}) {
295
+ const connections = this.getConnections(filter);
296
+
297
+ for (const connection of connections) {
298
+ connection.ws.close();
299
+ }
300
+
301
+ return connections.length;
302
+ }
303
+
304
+ getStats() {
305
+ return {
306
+ totalConnections: this.connections.size,
307
+ connectionsByKeypoint: this.groupConnectionsByKeypoint(),
308
+ connectionsByIp: this.groupConnectionsByIp(),
309
+ uptime: this.getUptime()
310
+ };
311
+ }
312
+
313
+ groupConnectionsByKeypoint() {
314
+ const groups = {};
315
+
316
+ for (const connection of this.connections.values()) {
317
+ const key = connection.keypointId || 'anonymous';
318
+ if (!groups[key]) groups[key] = 0;
319
+ groups[key]++;
320
+ }
321
+
322
+ return groups;
323
+ }
324
+
325
+ groupConnectionsByIp() {
326
+ const groups = {};
327
+
328
+ for (const connection of this.connections.values()) {
329
+ const ip = connection.ip;
330
+ if (!groups[ip]) groups[ip] = 0;
331
+ groups[ip]++;
332
+ }
333
+
334
+ return groups;
335
+ }
336
+
337
+ getUptime() {
338
+ if (!this.wss) return 0;
339
+ return Date.now() - this.wss.options.server?.startTime || Date.now();
340
+ }
341
+
342
+ cleanup() {
343
+ if (this.wss) {
344
+ this.wss.close();
345
+ this.wss = null;
346
+ }
347
+
348
+ this.connections.clear();
349
+ this.messageHandlers.clear();
350
+ }
351
+ }
@@ -0,0 +1,104 @@
1
+ export class AccessDecision {
2
+ constructor() {
3
+ this.allowed = false;
4
+ this.reason = '';
5
+ this.metadata = {};
6
+ this.violations = [];
7
+ this.timestamp = new Date();
8
+ this.evaluatedRules = [];
9
+ }
10
+
11
+ static allow(reason = '', metadata = {}) {
12
+ const decision = new AccessDecision();
13
+ decision.allowed = true;
14
+ decision.reason = reason;
15
+ decision.metadata = metadata;
16
+ return decision;
17
+ }
18
+
19
+ static deny(reason = '', violations = [], metadata = {}) {
20
+ const decision = new AccessDecision();
21
+ decision.allowed = false;
22
+ decision.reason = reason;
23
+ decision.violations = violations;
24
+ decision.metadata = metadata;
25
+ return decision;
26
+ }
27
+
28
+ addRuleResult(ruleName, result) {
29
+ this.evaluatedRules.push({
30
+ rule: ruleName,
31
+ allowed: result.allowed,
32
+ reason: result.reason,
33
+ timestamp: result.timestamp,
34
+ metadata: result.metadata
35
+ });
36
+
37
+ if (!result.allowed) {
38
+ this.violations.push({
39
+ rule: ruleName,
40
+ reason: result.reason,
41
+ metadata: result.metadata
42
+ });
43
+ }
44
+ }
45
+
46
+ merge(otherDecision) {
47
+ // Merge two decisions (for chained evaluations)
48
+ const merged = new AccessDecision();
49
+ merged.allowed = this.allowed && otherDecision.allowed;
50
+ merged.reason = this.allowed ? otherDecision.reason : this.reason;
51
+ merged.metadata = { ...this.metadata, ...otherDecision.metadata };
52
+ merged.violations = [...this.violations, ...otherDecision.violations];
53
+ merged.evaluatedRules = [
54
+ ...this.evaluatedRules,
55
+ ...otherDecision.evaluatedRules
56
+ ];
57
+
58
+ return merged;
59
+ }
60
+
61
+ toJSON() {
62
+ return {
63
+ allowed: this.allowed,
64
+ reason: this.reason,
65
+ violations: this.violations,
66
+ metadata: this.metadata,
67
+ timestamp: this.timestamp.toISOString(),
68
+ evaluatedRules: this.evaluatedRules
69
+ };
70
+ }
71
+
72
+ getSummary() {
73
+ const summary = {
74
+ allowed: this.allowed,
75
+ reason: this.reason,
76
+ ruleCount: this.evaluatedRules.length,
77
+ violationCount: this.violations.length,
78
+ passedRules: this.evaluatedRules.filter(r => r.allowed).length,
79
+ failedRules: this.evaluatedRules.filter(r => !r.allowed).length
80
+ };
81
+
82
+ if (this.violations.length > 0) {
83
+ summary.violations = this.violations.map(v => ({
84
+ rule: v.rule,
85
+ reason: v.reason
86
+ }));
87
+ }
88
+
89
+ return summary;
90
+ }
91
+
92
+ getDebugInfo() {
93
+ return {
94
+ decision: this.toJSON(),
95
+ context: {
96
+ ip: this.metadata.ip,
97
+ method: this.metadata.method,
98
+ path: this.metadata.path,
99
+ keypointId: this.metadata.keypointId,
100
+ scopes: this.metadata.scopes
101
+ }
102
+ };
103
+ }
104
+ }
@@ -0,0 +1,82 @@
1
+ export class PolicyEngine {
2
+ constructor() {
3
+ this.rules = [];
4
+ this.policies = new Map();
5
+ }
6
+
7
+ addPolicy(name, policyFn) {
8
+ this.policies.set(name, policyFn);
9
+ }
10
+
11
+ addRule(rule) {
12
+ this.rules.push(rule);
13
+ }
14
+
15
+ async evaluate(context) {
16
+ const decision = {
17
+ allowed: false,
18
+ reason: '',
19
+ metadata: {}
20
+ };
21
+
22
+ // Evaluate all rules
23
+ for (const rule of this.rules) {
24
+ const result = await rule.evaluate(context);
25
+ if (!result.allowed) {
26
+ return {
27
+ allowed: false,
28
+ reason: result.reason || 'Policy violation',
29
+ metadata: result.metadata
30
+ };
31
+ }
32
+ }
33
+
34
+ // Evaluate specific policies
35
+ if (context.keypoint) {
36
+ const policyName = context.keypoint.metadata.policy;
37
+ if (policyName && this.policies.has(policyName)) {
38
+ const policy = this.policies.get(policyName);
39
+ return await policy(context);
40
+ }
41
+ }
42
+
43
+ decision.allowed = true;
44
+ return decision;
45
+ }
46
+
47
+ // Predefined policies
48
+ allow(config) {
49
+ return async (context) => {
50
+ const { keypoint, request } = context;
51
+
52
+ // Check scope
53
+ if (config.scope) {
54
+ const hasScope = keypoint.hasScope(config.scope);
55
+ if (!hasScope) {
56
+ return {
57
+ allowed: false,
58
+ reason: `Insufficient scope. Required: ${config.scope}`
59
+ };
60
+ }
61
+ }
62
+
63
+ // Check method
64
+ if (config.method && config.method !== request.method) {
65
+ return {
66
+ allowed: false,
67
+ reason: `Method ${request.method} not allowed`
68
+ };
69
+ }
70
+
71
+ // Check protocol
72
+ if (config.protocol && !keypoint.validateProtocol(config.protocol)) {
73
+ return {
74
+ allowed: false,
75
+ reason: `Protocol not allowed`
76
+ };
77
+ }
78
+
79
+ return { allowed: true };
80
+ };
81
+ }
82
+ }