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.
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "keypointjs",
3
+ "version": "1.0.0",
4
+ "description": "KeypointJS Identity-First API Framework with Mandatory Authentication",
5
+ "main": "src/keypointJS.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/keypointJS.js",
9
+ "./keypoint": "./src/keypoint/Keypoint.js",
10
+ "./keypoint/context": "./src/keypoint/KeypointContext.js",
11
+ "./keypoint/storage": "./src/keypoint/KeypointStorage.js",
12
+ "./keypoint/validator": "./src/keypoint/KeypointValidator.js",
13
+ "./keypoint/scopes": "./src/keypoint/ScopeManager.js",
14
+ "./plugins": "./src/plugins/PluginManager.js",
15
+ "./plugins/audit": "./src/plugins/AuditLogger.js",
16
+ "./plugins/ratelimit": "./src/plugins/RateLimiter.js",
17
+ "./plugins/websocket": "./src/plugins/WebSocketGuard.js",
18
+ "./policy": "./src/policy/PolicyEngine.js",
19
+ "./policy/rules": "./src/policy/PolicyRule.js",
20
+ "./policy/decision": "./src/policy/AccessDecision.js",
21
+ "./router": "./src/router/MinimalRouter.js",
22
+ "./core": "./src/core/Context.js",
23
+ "./core/protocol": "./src/core/ProtocolEngine.js"
24
+ },
25
+ "scripts": {
26
+ "test": "node --test test/basic.test.js",
27
+ "test:all": "node --test test/*.test.js",
28
+ "test:integration": "node --test test/integration.test.js",
29
+ "test:performance": "node test/performance.test.js",
30
+ "test:coverage": "NODE_V8_COVERAGE=coverage node --test test/basic.test.js",
31
+ "start": "node examples/server.js",
32
+ "prepublishOnly": "npm test"
33
+ },
34
+ "keywords": [
35
+ "authentication",
36
+ "authorization",
37
+ "api",
38
+ "security",
39
+ "middleware",
40
+ "keypoint",
41
+ "nodejs",
42
+ "websocket",
43
+ "rate-limiting",
44
+ "audit-logging"
45
+ ],
46
+ "author": "AnasBex",
47
+ "website": "https://anasbex.vercel.app",
48
+ "license": "Apache-2.0",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/anasbex-dev/keypointjs.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/anasbex-dev/keypointjs/issues"
55
+ },
56
+ "homepage": "https://github.com/anasbex-dev/keypointjs#readme",
57
+ "engines": {
58
+ "node": ">=18.0.0"
59
+ },
60
+ "files": [
61
+ "src/",
62
+ "README.md",
63
+ "LICENSE"
64
+ ],
65
+ "dependencies": {
66
+ "ws": "^8.14.2"
67
+ },
68
+ "optionalDependencies": {
69
+ "bufferutil": "^4.0.8",
70
+ "utf-8-validate": "^6.0.3"
71
+ }
72
+ }
@@ -0,0 +1,104 @@
1
+ export class Context {
2
+ constructor(request) {
3
+ this.request = request;
4
+ this.response = {
5
+ status: 200,
6
+ headers: {},
7
+ body: null
8
+ };
9
+ this.state = {};
10
+ this.keypoint = null;
11
+ this.policyDecision = null;
12
+ this.pluginData = new Map();
13
+ }
14
+
15
+ // Response helpers
16
+ json(data, status = 200) {
17
+ this.response.status = status;
18
+ this.response.body = data;
19
+ this.response.headers['content-type'] = 'application/json';
20
+ return this;
21
+ }
22
+
23
+ text(data, status = 200) {
24
+ this.response.status = status;
25
+ this.response.body = data;
26
+ this.response.headers['content-type'] = 'text/plain';
27
+ return this;
28
+ }
29
+
30
+ html(data, status = 200) {
31
+ this.response.status = status;
32
+ this.response.body = data;
33
+ this.response.headers['content-type'] = 'text/html';
34
+ return this;
35
+ }
36
+
37
+ setHeader(key, value) {
38
+ this.response.headers[key.toLowerCase()] = value;
39
+ return this;
40
+ }
41
+
42
+ getHeader(key) {
43
+ return this.request.headers[key.toLowerCase()];
44
+ }
45
+
46
+ status(code) {
47
+ this.response.status = code;
48
+ return this;
49
+ }
50
+
51
+ // Query and param helpers
52
+ getQuery(key) {
53
+ return this.request.url.searchParams.get(key);
54
+ }
55
+
56
+ getAllQuery() {
57
+ const result = {};
58
+ for (const [key, value] of this.request.url.searchParams) {
59
+ result[key] = value;
60
+ }
61
+ return result;
62
+ }
63
+
64
+ // Body accessor
65
+ get body() {
66
+ return this.request.body;
67
+ }
68
+
69
+ // Protocol info
70
+ get protocol() {
71
+ return this.request.protocol;
72
+ }
73
+
74
+ get ip() {
75
+ return this.request.ip;
76
+ }
77
+
78
+ get method() {
79
+ return this.request.method;
80
+ }
81
+
82
+ get path() {
83
+ return this.request.url.pathname;
84
+ }
85
+
86
+ // State management
87
+ setState(key, value) {
88
+ this.state[key] = value;
89
+ return this;
90
+ }
91
+
92
+ getState(key) {
93
+ return this.state[key];
94
+ }
95
+
96
+ // Plugin data
97
+ setPluginData(pluginName, data) {
98
+ this.pluginData.set(pluginName, data);
99
+ }
100
+
101
+ getPluginData(pluginName) {
102
+ return this.pluginData.get(pluginName);
103
+ }
104
+ }
@@ -0,0 +1,144 @@
1
+ import crypto from 'crypto';
2
+
3
+ export class ProtocolEngine {
4
+ constructor(options = {}) {
5
+ this.options = {
6
+ maxBodySize: '1mb',
7
+ parseJSON: true,
8
+ parseForm: true,
9
+ ...options
10
+ };
11
+
12
+ this.supportedProtocols = new Set(['https', 'wss', 'http']);
13
+ }
14
+
15
+ async process(request) {
16
+ const context = {
17
+ id: crypto.randomUUID(),
18
+ timestamp: new Date(),
19
+ protocol: this.detectProtocol(request),
20
+ request: {
21
+ method: request.method,
22
+ url: new URL(request.url, `http://${request.headers.host}`),
23
+ headers: this.normalizeHeaders(request.headers),
24
+ ip: this.extractIP(request),
25
+ userAgent: request.headers['user-agent'] || '',
26
+ body: null
27
+ },
28
+ metadata: {}
29
+ };
30
+
31
+ // Parse body based on content-type
32
+ if (this.shouldParseBody(request)) {
33
+ context.request.body = await this.parseRequestBody(request);
34
+ }
35
+
36
+ // Validate protocol
37
+ if (!this.supportedProtocols.has(context.protocol)) {
38
+ throw new ProtocolError(`Unsupported protocol: ${context.protocol}`);
39
+ }
40
+
41
+ return context;
42
+ }
43
+
44
+ detectProtocol(request) {
45
+ const forwardedProto = request.headers['x-forwarded-proto'];
46
+ if (forwardedProto) return forwardedProto;
47
+
48
+ const isSecure = request.connection?.encrypted ||
49
+ request.socket?.encrypted ||
50
+ (request.headers['x-arr-ssl'] !== undefined);
51
+
52
+ return isSecure ? 'https' : 'http';
53
+ }
54
+
55
+ normalizeHeaders(headers) {
56
+ const normalized = {};
57
+ for (const [key, value] of Object.entries(headers)) {
58
+ normalized[key.toLowerCase()] = value;
59
+ }
60
+ return normalized;
61
+ }
62
+
63
+ extractIP(request) {
64
+ return request.headers['x-forwarded-for']?.split(',')[0].trim() ||
65
+ request.headers['x-real-ip'] ||
66
+ request.connection?.remoteAddress ||
67
+ request.socket?.remoteAddress ||
68
+ request.connection?.socket?.remoteAddress ||
69
+ '0.0.0.0';
70
+ }
71
+
72
+ shouldParseBody(request) {
73
+ const method = request.method.toUpperCase();
74
+ if (!['POST', 'PUT', 'PATCH'].includes(method)) return false;
75
+
76
+ const contentType = request.headers['content-type'] || '';
77
+ return contentType.includes('application/json') ||
78
+ contentType.includes('application/x-www-form-urlencoded');
79
+ }
80
+
81
+ async parseRequestBody(request) {
82
+ return new Promise((resolve, reject) => {
83
+ let body = '';
84
+ request.on('data', chunk => {
85
+ body += chunk.toString();
86
+
87
+ // Check size limit
88
+ if (body.length > this.parseSizeLimit()) {
89
+ request.destroy();
90
+ reject(new ProtocolError('Request body too large'));
91
+ }
92
+ });
93
+
94
+ request.on('end', () => {
95
+ try {
96
+ const contentType = request.headers['content-type'] || '';
97
+
98
+ if (contentType.includes('application/json')) {
99
+ resolve(JSON.parse(body));
100
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
101
+ const params = new URLSearchParams(body);
102
+ const result = {};
103
+ for (const [key, value] of params) {
104
+ result[key] = value;
105
+ }
106
+ resolve(result);
107
+ } else {
108
+ resolve(body);
109
+ }
110
+ } catch (error) {
111
+ reject(new ProtocolError(`Failed to parse body: ${error.message}`));
112
+ }
113
+ });
114
+
115
+ request.on('error', reject);
116
+ });
117
+ }
118
+
119
+ parseSizeLimit() {
120
+ const size = this.options.maxBodySize;
121
+ if (typeof size === 'number') return size;
122
+
123
+ const match = size.match(/^(\d+)(mb|kb|b)$/i);
124
+ if (!match) return 1024 * 1024; // 1MB default
125
+
126
+ const [, num, unit] = match;
127
+ const multiplier = {
128
+ 'b': 1,
129
+ 'kb': 1024,
130
+ 'mb': 1024 * 1024
131
+ }[unit.toLowerCase()];
132
+
133
+ return parseInt(num) * multiplier;
134
+ }
135
+ }
136
+
137
+ export class ProtocolError extends Error {
138
+ constructor(message, code = 400) {
139
+ super(message);
140
+ this.name = 'ProtocolError';
141
+ this.code = code;
142
+ this.timestamp = new Date();
143
+ }
144
+ }
@@ -0,0 +1,36 @@
1
+ export class Keypoint {
2
+ constructor(data) {
3
+ this.keyId = data.keyId;
4
+ this.secret = data.secret;
5
+ this.name = data.name || '';
6
+ this.scopes = data.scopes || [];
7
+ this.protocols = data.protocols || ['https'];
8
+ this.allowedOrigins = data.allowedOrigins || [];
9
+ this.allowedIps = data.allowedIps || [];
10
+ this.rateLimit = data.rateLimit || {
11
+ requests: 100,
12
+ window: 60 // seconds
13
+ };
14
+ this.expiresAt = data.expiresAt || null;
15
+ this.createdAt = data.createdAt || new Date();
16
+ this.metadata = data.metadata || {};
17
+ }
18
+
19
+ hasScope(requiredScope) {
20
+ return this.scopes.includes('*') || this.scopes.includes(requiredScope);
21
+ }
22
+
23
+ isExpired() {
24
+ return this.expiresAt && new Date() > this.expiresAt;
25
+ }
26
+
27
+ validateOrigin(origin) {
28
+ if (this.allowedOrigins.length === 0) return true;
29
+ return this.allowedOrigins.includes('*') ||
30
+ this.allowedOrigins.includes(origin);
31
+ }
32
+
33
+ validateProtocol(protocol) {
34
+ return this.protocols.includes(protocol);
35
+ }
36
+ }
@@ -0,0 +1,88 @@
1
+ import { Context } from '../core/Context.js';
2
+
3
+ export class KeypointContext extends Context {
4
+ constructor(request) {
5
+ super(request);
6
+ this.keypoint = null;
7
+ this.scopes = [];
8
+ this.rateLimit = null;
9
+ this.accessLog = [];
10
+ }
11
+
12
+ // Keypoint-specific methods
13
+ hasScope(scope) {
14
+ if (!this.keypoint) return false;
15
+ return this.keypoint.hasScope(scope);
16
+ }
17
+
18
+ hasAnyScope(scopes) {
19
+ if (!this.keypoint) return false;
20
+ return scopes.some(scope => this.keypoint.hasScope(scope));
21
+ }
22
+
23
+ hasAllScopes(scopes) {
24
+ if (!this.keypoint) return false;
25
+ return scopes.every(scope => this.keypoint.hasScope(scope));
26
+ }
27
+
28
+ getKeypointId() {
29
+ return this.keypoint?.keyId;
30
+ }
31
+
32
+ getKeypointMetadata() {
33
+ return this.keypoint?.metadata || {};
34
+ }
35
+
36
+ // Rate limit info
37
+ getRateLimitInfo() {
38
+ if (!this.keypoint) return null;
39
+
40
+ return {
41
+ limit: this.keypoint.rateLimit.requests,
42
+ window: this.keypoint.rateLimit.window,
43
+ remaining: this.rateLimit?.remaining || this.keypoint.rateLimit.requests
44
+ };
45
+ }
46
+
47
+ // Audit logging
48
+ logAccess(action, details = {}) {
49
+ this.accessLog.push({
50
+ timestamp: new Date(),
51
+ action,
52
+ keypointId: this.getKeypointId(),
53
+ ip: this.ip,
54
+ method: this.method,
55
+ path: this.path,
56
+ ...details
57
+ });
58
+ }
59
+
60
+ // Security validation
61
+ validateOrigin() {
62
+ if (!this.keypoint) return false;
63
+
64
+ const origin = this.getHeader('origin') || this.getHeader('referer');
65
+ if (!origin) return true; // No origin to validate
66
+
67
+ return this.keypoint.validateOrigin(origin);
68
+ }
69
+
70
+ validateProtocol() {
71
+ if (!this.keypoint) return false;
72
+ return this.keypoint.validateProtocol(this.protocol);
73
+ }
74
+
75
+ // Convenience getters
76
+ get isAuthenticated() {
77
+ return !!this.keypoint;
78
+ }
79
+
80
+ get isExpired() {
81
+ return this.keypoint?.isExpired() || false;
82
+ }
83
+
84
+ get allowedMethods() {
85
+ const policy = this.keypoint?.metadata?.allowedMethods;
86
+ return policy || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
87
+ }
88
+ }
@@ -0,0 +1,236 @@
1
+ export class KeypointStorage {
2
+ constructor(driver = 'memory') {
3
+ this.driver = driver;
4
+ this.store = new Map();
5
+ this.indexes = {
6
+ bySecret: new Map(),
7
+ byName: new Map(),
8
+ byScope: new Map()
9
+ };
10
+ }
11
+
12
+ async set(keypoint) {
13
+ if (!keypoint.keyId) {
14
+ throw new Error('Keypoint must have keyId');
15
+ }
16
+
17
+ this.store.set(keypoint.keyId, keypoint);
18
+
19
+ // Update indexes
20
+ if (keypoint.secret) {
21
+ this.indexes.bySecret.set(keypoint.secret, keypoint.keyId);
22
+ }
23
+
24
+ if (keypoint.name) {
25
+ if (!this.indexes.byName.has(keypoint.name)) {
26
+ this.indexes.byName.set(keypoint.name, new Set());
27
+ }
28
+ this.indexes.byName.get(keypoint.name).add(keypoint.keyId);
29
+ }
30
+
31
+ // Index by scopes
32
+ for (const scope of keypoint.scopes) {
33
+ if (!this.indexes.byScope.has(scope)) {
34
+ this.indexes.byScope.set(scope, new Set());
35
+ }
36
+ this.indexes.byScope.get(scope).add(keypoint.keyId);
37
+ }
38
+
39
+ return true;
40
+ }
41
+
42
+ async get(keyId) {
43
+ return this.store.get(keyId) || null;
44
+ }
45
+
46
+ async getBySecret(secret) {
47
+ const keyId = this.indexes.bySecret.get(secret);
48
+ return keyId ? await this.get(keyId) : null;
49
+ }
50
+
51
+ async getByName(name) {
52
+ const keyIds = this.indexes.byName.get(name);
53
+ if (!keyIds) return [];
54
+
55
+ const results = [];
56
+ for (const keyId of keyIds) {
57
+ const keypoint = await this.get(keyId);
58
+ if (keypoint) results.push(keypoint);
59
+ }
60
+ return results;
61
+ }
62
+
63
+ async getByScope(scope) {
64
+ const keyIds = this.indexes.byScope.get(scope);
65
+ if (!keyIds) return [];
66
+
67
+ const results = [];
68
+ for (const keyId of keyIds) {
69
+ const keypoint = await this.get(keyId);
70
+ if (keypoint) results.push(keypoint);
71
+ }
72
+ return results;
73
+ }
74
+
75
+ async update(keyId, updates) {
76
+ const existing = await this.get(keyId);
77
+ if (!existing) return false;
78
+
79
+ // Remove old indexes
80
+ await this.removeIndexes(existing);
81
+
82
+ // Apply updates
83
+ const updated = {
84
+ ...existing,
85
+ ...updates,
86
+ updatedAt: new Date()
87
+ };
88
+
89
+ // Save updated keypoint
90
+ await this.set(updated);
91
+ return true;
92
+ }
93
+
94
+ async delete(keyId) {
95
+ const keypoint = await this.get(keyId);
96
+ if (!keypoint) return false;
97
+
98
+ // Remove indexes
99
+ await this.removeIndexes(keypoint);
100
+
101
+ // Remove from store
102
+ return this.store.delete(keyId);
103
+ }
104
+
105
+ async removeIndexes(keypoint) {
106
+ // Remove from secret index
107
+ if (keypoint.secret) {
108
+ this.indexes.bySecret.delete(keypoint.secret);
109
+ }
110
+
111
+ // Remove from name index
112
+ if (keypoint.name) {
113
+ const nameSet = this.indexes.byName.get(keypoint.name);
114
+ if (nameSet) {
115
+ nameSet.delete(keypoint.keyId);
116
+ if (nameSet.size === 0) {
117
+ this.indexes.byName.delete(keypoint.name);
118
+ }
119
+ }
120
+ }
121
+
122
+ // Remove from scope indexes
123
+ for (const scope of keypoint.scopes) {
124
+ const scopeSet = this.indexes.byScope.get(scope);
125
+ if (scopeSet) {
126
+ scopeSet.delete(keypoint.keyId);
127
+ if (scopeSet.size === 0) {
128
+ this.indexes.byScope.delete(scope);
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ async list(filter = {}) {
135
+ const results = [];
136
+
137
+ for (const keypoint of this.store.values()) {
138
+ let match = true;
139
+
140
+ // Apply filters
141
+ if (filter.scope && !keypoint.hasScope(filter.scope)) {
142
+ match = false;
143
+ }
144
+
145
+ if (filter.protocol && !keypoint.protocols.includes(filter.protocol)) {
146
+ match = false;
147
+ }
148
+
149
+ if (filter.expired !== undefined) {
150
+ const isExpired = keypoint.isExpired();
151
+ if (filter.expired !== isExpired) {
152
+ match = false;
153
+ }
154
+ }
155
+
156
+ if (filter.name && keypoint.name !== filter.name) {
157
+ match = false;
158
+ }
159
+
160
+ if (match) {
161
+ results.push(keypoint);
162
+ }
163
+ }
164
+
165
+ return results;
166
+ }
167
+
168
+ async count() {
169
+ return this.store.size;
170
+ }
171
+
172
+ async cleanupExpired() {
173
+ const expired = [];
174
+
175
+ for (const [keyId, keypoint] of this.store) {
176
+ if (keypoint.isExpired()) {
177
+ expired.push(keyId);
178
+ }
179
+ }
180
+
181
+ for (const keyId of expired) {
182
+ await this.delete(keyId);
183
+ }
184
+
185
+ return expired.length;
186
+ }
187
+ }
188
+
189
+ // Memory storage implementation (default)
190
+ export class MemoryKeypointStorage extends KeypointStorage {
191
+ constructor() {
192
+ super('memory');
193
+ }
194
+ }
195
+
196
+ // File storage implementation
197
+ export class FileKeypointStorage extends KeypointStorage {
198
+ constructor(filePath) {
199
+ super('file');
200
+ this.filePath = filePath;
201
+ this.loadFromFile();
202
+ }
203
+
204
+ async loadFromFile() {
205
+ try {
206
+ const fs = await import('fs/promises');
207
+ const data = await fs.readFile(this.filePath, 'utf-8');
208
+ const parsed = JSON.parse(data);
209
+
210
+ for (const item of parsed) {
211
+ await this.set(item);
212
+ }
213
+ } catch (error) {
214
+ // File doesn't exist or is empty
215
+ await this.saveToFile();
216
+ }
217
+ }
218
+
219
+ async saveToFile() {
220
+ const fs = await import('fs/promises');
221
+ const data = Array.from(this.store.values());
222
+ await fs.writeFile(this.filePath, JSON.stringify(data, null, 2));
223
+ }
224
+
225
+ async set(keypoint) {
226
+ await super.set(keypoint);
227
+ await this.saveToFile();
228
+ return true;
229
+ }
230
+
231
+ async delete(keyId) {
232
+ const result = await super.delete(keyId);
233
+ if (result) await this.saveToFile();
234
+ return result;
235
+ }
236
+ }