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/LICENSE +201 -0
- package/README.md +808 -0
- package/package.json +72 -0
- package/src/core/Context.js +104 -0
- package/src/core/ProtocolEngine.js +144 -0
- package/src/keypoint/Keypoint.js +36 -0
- package/src/keypoint/KeypointContext.js +88 -0
- package/src/keypoint/KeypointStorage.js +236 -0
- package/src/keypoint/KeypointValidator.js +51 -0
- package/src/keypoint/ScopeManager.js +206 -0
- package/src/keypointJS.js +779 -0
- package/src/plugins/AuditLogger.js +294 -0
- package/src/plugins/PluginManager.js +303 -0
- package/src/plugins/RateLimiter.js +24 -0
- package/src/plugins/WebSocketGuard.js +351 -0
- package/src/policy/AccessDecision.js +104 -0
- package/src/policy/PolicyEngine.js +82 -0
- package/src/policy/PolicyRule.js +246 -0
- package/src/router/MinimalRouter.js +41 -0
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
|
+
}
|