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
|
@@ -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
|
+
}
|