@objectstack/core 2.0.1 → 2.0.2
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +7 -0
- package/dist/index.cjs +121 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -11
- package/dist/index.d.ts +10 -11
- package/dist/index.js +121 -42
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/hot-reload.ts +4 -12
- package/src/qa/runner.ts +12 -4
- package/src/security/plugin-permission-enforcer.ts +29 -9
- package/src/security/plugin-signature-verifier.ts +48 -7
- package/src/security/sandbox-runtime.ts +61 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Microkernel Core for ObjectStack",
|
|
6
6
|
"type": "module",
|
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"typescript": "^5.0.0",
|
|
18
18
|
"vitest": "^4.0.18",
|
|
19
|
-
"@types/node": "^25.
|
|
19
|
+
"@types/node": "^25.2.2"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"pino": "^10.3.0",
|
|
23
23
|
"pino-pretty": "^13.1.3",
|
|
24
|
-
"zod": "^3.
|
|
25
|
-
"@objectstack/spec": "2.0.
|
|
24
|
+
"zod": "^4.3.6",
|
|
25
|
+
"@objectstack/spec": "2.0.2"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"pino": "^8.0.0"
|
package/src/hot-reload.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
3
5
|
import type {
|
|
4
6
|
HotReloadConfig,
|
|
5
7
|
PluginStateSnapshot
|
|
@@ -136,21 +138,11 @@ class PluginStateManager {
|
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
/**
|
|
139
|
-
* Calculate
|
|
140
|
-
* WARNING: This is a simple hash for demo purposes.
|
|
141
|
-
* In production, use a cryptographic hash like SHA-256.
|
|
141
|
+
* Calculate checksum for state verification using SHA-256.
|
|
142
142
|
*/
|
|
143
143
|
private calculateChecksum(state: Record<string, any>): string {
|
|
144
|
-
// Simple checksum using JSON serialization
|
|
145
|
-
// TODO: Replace with crypto.createHash('sha256') for production
|
|
146
144
|
const stateStr = JSON.stringify(state);
|
|
147
|
-
|
|
148
|
-
for (let i = 0; i < stateStr.length; i++) {
|
|
149
|
-
const char = stateStr.charCodeAt(i);
|
|
150
|
-
hash = ((hash << 5) - hash) + char;
|
|
151
|
-
hash = hash & hash; // Convert to 32-bit integer
|
|
152
|
-
}
|
|
153
|
-
return hash.toString(16);
|
|
145
|
+
return createHash('sha256').update(stateStr).digest('hex');
|
|
154
146
|
}
|
|
155
147
|
|
|
156
148
|
/**
|
package/src/qa/runner.ts
CHANGED
|
@@ -131,10 +131,18 @@ export class TestRunner {
|
|
|
131
131
|
return result;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
private resolveVariables(action: QA.TestAction,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
private resolveVariables(action: QA.TestAction, context: Record<string, unknown>): QA.TestAction {
|
|
135
|
+
const actionStr = JSON.stringify(action);
|
|
136
|
+
const resolved = actionStr.replace(/\{\{([^}]+)\}\}/g, (_match, varPath: string) => {
|
|
137
|
+
const value = this.getValueByPath(context, varPath.trim());
|
|
138
|
+
if (value === undefined) return _match; // Keep unresolved
|
|
139
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(resolved) as QA.TestAction;
|
|
143
|
+
} catch {
|
|
144
|
+
return action; // Fallback to original if parse fails
|
|
145
|
+
}
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
private getValueByPath(obj: unknown, path: string): unknown {
|
|
@@ -306,45 +306,65 @@ export class PluginPermissionEnforcer {
|
|
|
306
306
|
});
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
-
private
|
|
309
|
+
private matchGlob(pattern: string, str: string): boolean {
|
|
310
|
+
const regexStr = pattern
|
|
311
|
+
.split('**')
|
|
312
|
+
.map(segment => {
|
|
313
|
+
const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
314
|
+
return escaped.replace(/\*/g, '[^/]*');
|
|
315
|
+
})
|
|
316
|
+
.join('.*');
|
|
317
|
+
return new RegExp(`^${regexStr}$`).test(str);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private checkFileRead(capabilities: PluginCapability[], path: string): boolean {
|
|
310
321
|
// Check if plugin has capability to read this file
|
|
311
322
|
return capabilities.some(cap => {
|
|
312
323
|
const protocolId = cap.protocol.id;
|
|
313
324
|
|
|
314
325
|
// Check for file read capability
|
|
315
326
|
if (protocolId.includes('protocol.filesystem.read')) {
|
|
316
|
-
|
|
317
|
-
|
|
327
|
+
const paths = cap.metadata?.paths;
|
|
328
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
return paths.some(p => typeof p === 'string' && this.matchGlob(p, path));
|
|
318
332
|
}
|
|
319
333
|
|
|
320
334
|
return false;
|
|
321
335
|
});
|
|
322
336
|
}
|
|
323
337
|
|
|
324
|
-
private checkFileWrite(capabilities: PluginCapability[],
|
|
338
|
+
private checkFileWrite(capabilities: PluginCapability[], path: string): boolean {
|
|
325
339
|
// Check if plugin has capability to write this file
|
|
326
340
|
return capabilities.some(cap => {
|
|
327
341
|
const protocolId = cap.protocol.id;
|
|
328
342
|
|
|
329
343
|
// Check for file write capability
|
|
330
344
|
if (protocolId.includes('protocol.filesystem.write')) {
|
|
331
|
-
|
|
332
|
-
|
|
345
|
+
const paths = cap.metadata?.paths;
|
|
346
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
return paths.some(p => typeof p === 'string' && this.matchGlob(p, path));
|
|
333
350
|
}
|
|
334
351
|
|
|
335
352
|
return false;
|
|
336
353
|
});
|
|
337
354
|
}
|
|
338
355
|
|
|
339
|
-
private checkNetworkAccess(capabilities: PluginCapability[],
|
|
356
|
+
private checkNetworkAccess(capabilities: PluginCapability[], url: string): boolean {
|
|
340
357
|
// Check if plugin has capability to access this URL
|
|
341
358
|
return capabilities.some(cap => {
|
|
342
359
|
const protocolId = cap.protocol.id;
|
|
343
360
|
|
|
344
361
|
// Check for network capability
|
|
345
362
|
if (protocolId.includes('protocol.network')) {
|
|
346
|
-
|
|
347
|
-
|
|
363
|
+
const hosts = cap.metadata?.hosts;
|
|
364
|
+
if (!Array.isArray(hosts) || hosts.length === 0) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
return hosts.some(h => typeof h === 'string' && this.matchGlob(h, url));
|
|
348
368
|
}
|
|
349
369
|
|
|
350
370
|
return false;
|
|
@@ -336,14 +336,55 @@ export class PluginSignatureVerifier {
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
private async verifyCryptoSignatureBrowser(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
339
|
+
data: string,
|
|
340
|
+
signature: string,
|
|
341
|
+
publicKey: string
|
|
342
342
|
): Promise<boolean> {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
try {
|
|
344
|
+
const subtle = globalThis.crypto?.subtle;
|
|
345
|
+
if (!subtle) {
|
|
346
|
+
this.logger.error('SubtleCrypto not available in this environment');
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Decode PEM public key to raw DER bytes
|
|
351
|
+
const pemBody = publicKey
|
|
352
|
+
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
|
353
|
+
.replace(/-----END PUBLIC KEY-----/, '')
|
|
354
|
+
.replace(/\s/g, '');
|
|
355
|
+
const keyBytes = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
|
|
356
|
+
|
|
357
|
+
// Configure algorithms based on RS256 or ES256
|
|
358
|
+
let importAlgorithm: { name: string; hash?: string; namedCurve?: string };
|
|
359
|
+
let verifyAlgorithm: { name: string; hash?: string };
|
|
360
|
+
|
|
361
|
+
if (this.config.algorithm === 'ES256') {
|
|
362
|
+
importAlgorithm = { name: 'ECDSA', namedCurve: 'P-256' };
|
|
363
|
+
verifyAlgorithm = { name: 'ECDSA', hash: 'SHA-256' };
|
|
364
|
+
} else {
|
|
365
|
+
importAlgorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
366
|
+
verifyAlgorithm = { name: 'RSASSA-PKCS1-v1_5' };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const cryptoKey = await subtle.importKey(
|
|
370
|
+
'spki',
|
|
371
|
+
keyBytes,
|
|
372
|
+
importAlgorithm,
|
|
373
|
+
false,
|
|
374
|
+
['verify']
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// Decode base64 signature to ArrayBuffer
|
|
378
|
+
const signatureBytes = Uint8Array.from(atob(signature), c => c.charCodeAt(0));
|
|
379
|
+
|
|
380
|
+
// Encode data to ArrayBuffer
|
|
381
|
+
const dataBytes = new TextEncoder().encode(data);
|
|
382
|
+
|
|
383
|
+
return await subtle.verify(verifyAlgorithm, cryptoKey, signatureBytes, dataBytes);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
this.logger.error('Browser signature verification failed', error as Error);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
347
388
|
}
|
|
348
389
|
|
|
349
390
|
private validateConfig(): void {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
+
import nodePath from 'node:path';
|
|
4
|
+
|
|
3
5
|
import type {
|
|
4
6
|
SandboxConfig
|
|
5
7
|
} from '@objectstack/spec/kernel';
|
|
@@ -44,6 +46,8 @@ export interface SandboxContext {
|
|
|
44
46
|
* and access controls
|
|
45
47
|
*/
|
|
46
48
|
export class PluginSandboxRuntime {
|
|
49
|
+
private static readonly MONITORING_INTERVAL_MS = 5000;
|
|
50
|
+
|
|
47
51
|
private logger: ObjectLogger;
|
|
48
52
|
|
|
49
53
|
// Active sandboxes (pluginId -> context)
|
|
@@ -52,6 +56,10 @@ export class PluginSandboxRuntime {
|
|
|
52
56
|
// Resource monitoring intervals
|
|
53
57
|
private monitoringIntervals = new Map<string, NodeJS.Timeout>();
|
|
54
58
|
|
|
59
|
+
// Per-plugin resource baselines for delta tracking
|
|
60
|
+
private memoryBaselines = new Map<string, number>();
|
|
61
|
+
private cpuBaselines = new Map<string, { user: number; system: number }>();
|
|
62
|
+
|
|
55
63
|
constructor(logger: ObjectLogger) {
|
|
56
64
|
this.logger = logger.child({ component: 'SandboxRuntime' });
|
|
57
65
|
}
|
|
@@ -77,6 +85,11 @@ export class PluginSandboxRuntime {
|
|
|
77
85
|
|
|
78
86
|
this.sandboxes.set(pluginId, context);
|
|
79
87
|
|
|
88
|
+
// Capture resource baselines for per-plugin delta tracking
|
|
89
|
+
const baselineMemory = getMemoryUsage();
|
|
90
|
+
this.memoryBaselines.set(pluginId, baselineMemory.heapUsed);
|
|
91
|
+
this.cpuBaselines.set(pluginId, process.cpuUsage());
|
|
92
|
+
|
|
80
93
|
// Start resource monitoring
|
|
81
94
|
this.startResourceMonitoring(pluginId);
|
|
82
95
|
|
|
@@ -102,6 +115,8 @@ export class PluginSandboxRuntime {
|
|
|
102
115
|
// Stop monitoring
|
|
103
116
|
this.stopResourceMonitoring(pluginId);
|
|
104
117
|
|
|
118
|
+
this.memoryBaselines.delete(pluginId);
|
|
119
|
+
this.cpuBaselines.delete(pluginId);
|
|
105
120
|
this.sandboxes.delete(pluginId);
|
|
106
121
|
|
|
107
122
|
this.logger.info('Sandbox destroyed', { pluginId });
|
|
@@ -142,12 +157,11 @@ export class PluginSandboxRuntime {
|
|
|
142
157
|
|
|
143
158
|
/**
|
|
144
159
|
* Check file system access
|
|
145
|
-
*
|
|
146
|
-
* resolution with path.resolve() and path.normalize() to prevent traversal.
|
|
160
|
+
* Uses path.resolve() and path.normalize() to prevent directory traversal.
|
|
147
161
|
*/
|
|
148
162
|
private checkFileAccess(
|
|
149
163
|
config: SandboxConfig,
|
|
150
|
-
|
|
164
|
+
filePath?: string
|
|
151
165
|
): { allowed: boolean; reason?: string } {
|
|
152
166
|
if (config.level === 'none') {
|
|
153
167
|
return { allowed: true };
|
|
@@ -158,36 +172,36 @@ export class PluginSandboxRuntime {
|
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
// If no path specified, check general access
|
|
161
|
-
if (!
|
|
175
|
+
if (!filePath) {
|
|
162
176
|
return { allowed: config.filesystem.mode !== 'none' };
|
|
163
177
|
}
|
|
164
178
|
|
|
165
|
-
//
|
|
166
|
-
// Check allowed paths
|
|
179
|
+
// Check allowed paths using proper path resolution to prevent directory traversal
|
|
167
180
|
const allowedPaths = config.filesystem.allowedPaths || [];
|
|
181
|
+
const resolvedPath = nodePath.normalize(nodePath.resolve(filePath));
|
|
168
182
|
const isAllowed = allowedPaths.some(allowed => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return path.startsWith(allowed);
|
|
183
|
+
const resolvedAllowed = nodePath.normalize(nodePath.resolve(allowed));
|
|
184
|
+
return resolvedPath.startsWith(resolvedAllowed);
|
|
172
185
|
});
|
|
173
186
|
|
|
174
187
|
if (allowedPaths.length > 0 && !isAllowed) {
|
|
175
188
|
return {
|
|
176
189
|
allowed: false,
|
|
177
|
-
reason: `Path not in allowed list: ${
|
|
190
|
+
reason: `Path not in allowed list: ${filePath}`
|
|
178
191
|
};
|
|
179
192
|
}
|
|
180
193
|
|
|
181
|
-
// Check denied paths
|
|
194
|
+
// Check denied paths using proper path resolution
|
|
182
195
|
const deniedPaths = config.filesystem.deniedPaths || [];
|
|
183
196
|
const isDenied = deniedPaths.some(denied => {
|
|
184
|
-
|
|
197
|
+
const resolvedDenied = nodePath.normalize(nodePath.resolve(denied));
|
|
198
|
+
return resolvedPath.startsWith(resolvedDenied);
|
|
185
199
|
});
|
|
186
200
|
|
|
187
201
|
if (isDenied) {
|
|
188
202
|
return {
|
|
189
203
|
allowed: false,
|
|
190
|
-
reason: `Path is explicitly denied: ${
|
|
204
|
+
reason: `Path is explicitly denied: ${filePath}`
|
|
191
205
|
};
|
|
192
206
|
}
|
|
193
207
|
|
|
@@ -196,8 +210,7 @@ export class PluginSandboxRuntime {
|
|
|
196
210
|
|
|
197
211
|
/**
|
|
198
212
|
* Check network access
|
|
199
|
-
*
|
|
200
|
-
* parsing with new URL() and check hostname property.
|
|
213
|
+
* Uses URL parsing to properly validate hostnames.
|
|
201
214
|
*/
|
|
202
215
|
private checkNetworkAccess(
|
|
203
216
|
config: SandboxConfig,
|
|
@@ -221,14 +234,19 @@ export class PluginSandboxRuntime {
|
|
|
221
234
|
return { allowed: (config.network.mode as string) !== 'none' };
|
|
222
235
|
}
|
|
223
236
|
|
|
224
|
-
//
|
|
237
|
+
// Parse URL and check hostname against allowed/denied hosts
|
|
238
|
+
let parsedHostname: string;
|
|
239
|
+
try {
|
|
240
|
+
parsedHostname = new URL(url).hostname;
|
|
241
|
+
} catch {
|
|
242
|
+
return { allowed: false, reason: `Invalid URL: ${url}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
225
245
|
// Check allowed hosts
|
|
226
246
|
const allowedHosts = config.network.allowedHosts || [];
|
|
227
247
|
if (allowedHosts.length > 0) {
|
|
228
248
|
const isAllowed = allowedHosts.some(host => {
|
|
229
|
-
|
|
230
|
-
// TODO: Use proper URL parsing
|
|
231
|
-
return url.includes(host);
|
|
249
|
+
return parsedHostname === host;
|
|
232
250
|
});
|
|
233
251
|
|
|
234
252
|
if (!isAllowed) {
|
|
@@ -242,7 +260,7 @@ export class PluginSandboxRuntime {
|
|
|
242
260
|
// Check denied hosts
|
|
243
261
|
const deniedHosts = config.network.deniedHosts || [];
|
|
244
262
|
const isDenied = deniedHosts.some(host => {
|
|
245
|
-
return
|
|
263
|
+
return parsedHostname === host;
|
|
246
264
|
});
|
|
247
265
|
|
|
248
266
|
if (isDenied) {
|
|
@@ -352,10 +370,10 @@ export class PluginSandboxRuntime {
|
|
|
352
370
|
* Start monitoring resource usage
|
|
353
371
|
*/
|
|
354
372
|
private startResourceMonitoring(pluginId: string): void {
|
|
355
|
-
// Monitor
|
|
373
|
+
// Monitor at the configured interval
|
|
356
374
|
const interval = setInterval(() => {
|
|
357
375
|
this.updateResourceUsage(pluginId);
|
|
358
|
-
},
|
|
376
|
+
}, PluginSandboxRuntime.MONITORING_INTERVAL_MS);
|
|
359
377
|
|
|
360
378
|
this.monitoringIntervals.set(pluginId, interval);
|
|
361
379
|
}
|
|
@@ -374,10 +392,9 @@ export class PluginSandboxRuntime {
|
|
|
374
392
|
/**
|
|
375
393
|
* Update resource usage statistics
|
|
376
394
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* per-plugin
|
|
380
|
-
* plugin boundaries.
|
|
395
|
+
* Tracks per-plugin memory and CPU usage using delta from baseline
|
|
396
|
+
* captured at sandbox creation time. This is an approximation since
|
|
397
|
+
* true per-plugin isolation isn't possible in a single Node.js process.
|
|
381
398
|
*/
|
|
382
399
|
private updateResourceUsage(pluginId: string): void {
|
|
383
400
|
const context = this.sandboxes.get(pluginId);
|
|
@@ -388,19 +405,27 @@ export class PluginSandboxRuntime {
|
|
|
388
405
|
// In a real implementation, this would collect actual metrics
|
|
389
406
|
// For now, this is a placeholder structure
|
|
390
407
|
|
|
391
|
-
// Update memory usage
|
|
392
|
-
// TODO: Implement per-plugin memory tracking
|
|
408
|
+
// Update memory usage using delta from baseline for per-plugin approximation
|
|
393
409
|
const memoryUsage = getMemoryUsage();
|
|
394
|
-
|
|
410
|
+
const memoryBaseline = this.memoryBaselines.get(pluginId) ?? 0;
|
|
411
|
+
const memoryDelta = Math.max(0, memoryUsage.heapUsed - memoryBaseline);
|
|
412
|
+
context.resourceUsage.memory.current = memoryDelta;
|
|
395
413
|
context.resourceUsage.memory.peak = Math.max(
|
|
396
414
|
context.resourceUsage.memory.peak,
|
|
397
|
-
|
|
415
|
+
memoryDelta
|
|
398
416
|
);
|
|
399
417
|
|
|
400
|
-
// Update CPU usage
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
418
|
+
// Update CPU usage using delta from baseline for per-plugin approximation
|
|
419
|
+
const cpuBaseline = this.cpuBaselines.get(pluginId) ?? { user: 0, system: 0 };
|
|
420
|
+
const cpuCurrent = process.cpuUsage();
|
|
421
|
+
const cpuDeltaUser = cpuCurrent.user - cpuBaseline.user;
|
|
422
|
+
const cpuDeltaSystem = cpuCurrent.system - cpuBaseline.system;
|
|
423
|
+
// Convert microseconds to a percentage approximation over the monitoring interval
|
|
424
|
+
const totalCpuMicros = cpuDeltaUser + cpuDeltaSystem;
|
|
425
|
+
const intervalMicros = PluginSandboxRuntime.MONITORING_INTERVAL_MS * 1000;
|
|
426
|
+
context.resourceUsage.cpu.current = (totalCpuMicros / intervalMicros) * 100;
|
|
427
|
+
// Update baseline for next interval
|
|
428
|
+
this.cpuBaselines.set(pluginId, cpuCurrent);
|
|
404
429
|
|
|
405
430
|
// Check for violations
|
|
406
431
|
const { withinLimits, violations } = this.checkResourceLimits(pluginId);
|
|
@@ -429,6 +454,8 @@ export class PluginSandboxRuntime {
|
|
|
429
454
|
}
|
|
430
455
|
|
|
431
456
|
this.sandboxes.clear();
|
|
457
|
+
this.memoryBaselines.clear();
|
|
458
|
+
this.cpuBaselines.clear();
|
|
432
459
|
|
|
433
460
|
this.logger.info('Sandbox runtime shutdown complete');
|
|
434
461
|
}
|