@anteros/core 0.0.19 → 0.0.21
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/README.md +90 -5
- package/database/decorator.ts +1 -1
- package/database/mongodbadapter.ts +64 -40
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +115 -32
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +6 -3
- package/probe-cache.ts +16 -0
- package/server/api.ts +35 -28
- package/server/boot.ts +78 -16
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +192 -18
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- package/tests/api.test.ts +14 -12
- package/tests/cache.test.ts +124 -0
- package/tests/crypto.test.ts +101 -0
- package/tests/fixtures/heavy/collections/items.model.ts +8 -0
- package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
- package/tests/fixtures/metrics-worker.ts +21 -0
- package/tests/hono.test.ts +211 -0
- package/tests/mcp.test.ts +186 -0
- package/tests/metrics-cluster.test.ts +103 -0
- package/tests/scripts-heavy.test.ts +49 -0
- package/tests/security.test.ts +102 -0
- package/tests/ts1011-test.ts +15 -0
- package/tests/utils.test.ts +113 -0
- package/types/api.d.ts +11 -0
- package/types/collection.d.ts +20 -5
- package/types/config.d.ts +20 -3
- package/types/hook.d.ts +16 -13
- package/types/mcp.d.ts +81 -0
- package/types/scripts.d.ts +14 -1
- package/utils/cache.ts +20 -1
- package/utils/crypto.ts +25 -8
- package/utils/func.ts +178 -18
package/lib/files.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { getFileCollection } from "../database/file";
|
|
|
2
2
|
import { useRest } from "../database/rest";
|
|
3
3
|
import { cfg } from "../server/config";
|
|
4
4
|
import { AppError } from "./error";
|
|
5
|
+
import { S3Client } from "bun";
|
|
5
6
|
import type { FileCollection } from "../types/file";
|
|
6
7
|
import path from "path";
|
|
7
8
|
import fs from "fs/promises";
|
|
@@ -53,7 +54,8 @@ export function createDiskStorage(baseDir?: string): FileStorage {
|
|
|
53
54
|
const filename = `${meta.id}${ext}`;
|
|
54
55
|
const filepath = path.join(dir, filename);
|
|
55
56
|
const buffer = await file.arrayBuffer();
|
|
56
|
-
|
|
57
|
+
// Faille 17: 'wx' — fail if file already exists (no silent overwrite)
|
|
58
|
+
await fs.writeFile(filepath, new Uint8Array(buffer), { flag: 'wx' });
|
|
57
59
|
const outPath = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
58
60
|
return { path: outPath, size: buffer.byteLength };
|
|
59
61
|
},
|
|
@@ -83,6 +85,14 @@ export type S3Config = {
|
|
|
83
85
|
};
|
|
84
86
|
|
|
85
87
|
export function createS3Storage(config: S3Config): FileStorage {
|
|
88
|
+
const client = new S3Client({
|
|
89
|
+
region: config.region,
|
|
90
|
+
bucket: config.bucket,
|
|
91
|
+
accessKeyId: config.accessKeyId,
|
|
92
|
+
secretAccessKey: config.secretAccessKey,
|
|
93
|
+
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
94
|
+
});
|
|
95
|
+
|
|
86
96
|
return {
|
|
87
97
|
async save(tenant_id, collection, file, meta) {
|
|
88
98
|
const { subpath } = meta;
|
|
@@ -90,42 +100,21 @@ export function createS3Storage(config: S3Config): FileStorage {
|
|
|
90
100
|
const filename = `${meta.id}${ext}`;
|
|
91
101
|
const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
92
102
|
const buffer = await file.arrayBuffer();
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
? `${config.endpoint}/${config.bucket}/${key}`
|
|
96
|
-
: `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
97
|
-
const response = await fetch(url, {
|
|
98
|
-
method: 'PUT',
|
|
99
|
-
headers: {
|
|
100
|
-
'Content-Type': meta.mimetype,
|
|
101
|
-
'Content-Length': String(buffer.byteLength),
|
|
102
|
-
},
|
|
103
|
-
body: buffer,
|
|
104
|
-
});
|
|
105
|
-
if (!response.ok) {
|
|
106
|
-
throw new AppError(`S3 upload failed: ${response.statusText}`, {
|
|
107
|
-
code: 'S3_UPLOAD_ERROR', status: 500,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
103
|
+
// Native Bun S3 client (handles multipart, retries, signing)
|
|
104
|
+
await client.write(key, buffer, { type: meta.mimetype });
|
|
110
105
|
return { path: key, size: buffer.byteLength };
|
|
111
106
|
},
|
|
112
107
|
|
|
113
108
|
async getStream(tenant_id, collection, fileId, filename, subpath) {
|
|
114
109
|
const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const response = await fetch(url);
|
|
119
|
-
if (!response.ok) return null;
|
|
120
|
-
return response.body;
|
|
110
|
+
const s3file = client.file(key);
|
|
111
|
+
if (!(await s3file.exists())) return null;
|
|
112
|
+
return s3file.stream();
|
|
121
113
|
},
|
|
122
114
|
|
|
123
115
|
async delete(tenant_id, collection, fileId, filename, subpath) {
|
|
124
116
|
const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
125
|
-
|
|
126
|
-
? `${config.endpoint}/${config.bucket}/${key}`
|
|
127
|
-
: `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
|
|
128
|
-
await fetch(url, { method: 'DELETE' });
|
|
117
|
+
await client.delete(key).catch(() => {});
|
|
129
118
|
},
|
|
130
119
|
};
|
|
131
120
|
}
|
|
@@ -146,6 +135,15 @@ async function transformImage(
|
|
|
146
135
|
const format = options.format || 'webp';
|
|
147
136
|
const quality = options.quality ?? 80;
|
|
148
137
|
|
|
138
|
+
// Faille 20: validate params (reject NaN / out-of-range)
|
|
139
|
+
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
|
|
140
|
+
throw new AppError('Invalid quality parameter', { code: 'INVALID_TRANSFORM', status: 400 });
|
|
141
|
+
}
|
|
142
|
+
if ((options.width != null && (!Number.isFinite(options.width) || options.width <= 0))
|
|
143
|
+
|| (options.height != null && (!Number.isFinite(options.height) || options.height <= 0))) {
|
|
144
|
+
throw new AppError('Invalid width/height parameter', { code: 'INVALID_TRANSFORM', status: 400 });
|
|
145
|
+
}
|
|
146
|
+
|
|
149
147
|
// Convertir ReadableStream en Uint8Array si nécessaire
|
|
150
148
|
let buffer: Uint8Array;
|
|
151
149
|
if (input instanceof Uint8Array) {
|
|
@@ -167,6 +165,12 @@ async function transformImage(
|
|
|
167
165
|
}
|
|
168
166
|
}
|
|
169
167
|
|
|
168
|
+
// Faille 20: only transform real images (magic bytes check)
|
|
169
|
+
const detected = detectMimeFromBuffer(buffer);
|
|
170
|
+
if (!detected || !detected.startsWith('image/')) {
|
|
171
|
+
throw new AppError('Not an image file', { code: 'NOT_AN_IMAGE', status: 400 });
|
|
172
|
+
}
|
|
173
|
+
|
|
170
174
|
// Utiliser Bun.Image (natif, zéro dépendance)
|
|
171
175
|
const img = new Bun.Image(buffer);
|
|
172
176
|
|
|
@@ -249,6 +253,28 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
249
253
|
});
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
// Faille 4: validate magic bytes — don't trust client-declared Content-Type
|
|
257
|
+
const fullBuffer = new Uint8Array(await file.arrayBuffer());
|
|
258
|
+
const probe = fullBuffer.subarray(0, 4096);
|
|
259
|
+
const detected = detectMimeFromBuffer(probe);
|
|
260
|
+
const declaredMime = mimetype.toLowerCase();
|
|
261
|
+
const isImageDeclared = declaredMime.startsWith('image/');
|
|
262
|
+
const isImageDetected = detected?.startsWith('image/') ?? false;
|
|
263
|
+
|
|
264
|
+
// Reject content that lies about being an image (e.g. HTML/JS uploaded as image/png)
|
|
265
|
+
if (isImageDeclared && !isImageDetected) {
|
|
266
|
+
throw new AppError(`File content does not match declared MIME type '${mimetype}'`, {
|
|
267
|
+
code: 'MIMETYPE_MISMATCH', status: 400,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Hardening: block SVG with embedded script (stored XSS)
|
|
272
|
+
if (detected === 'image/svg+xml' && svgContainsScript(probe)) {
|
|
273
|
+
throw new AppError('SVG with executable content is not allowed', {
|
|
274
|
+
code: 'SVG_SCRIPT_NOT_ALLOWED', status: 400,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
252
278
|
// Validate file size
|
|
253
279
|
const maxSize = col.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
|
|
254
280
|
if (file.size > maxSize) {
|
|
@@ -358,20 +384,39 @@ export async function handleServe(
|
|
|
358
384
|
fileId: string,
|
|
359
385
|
filename: string,
|
|
360
386
|
transform?: TransformOptions,
|
|
361
|
-
): Promise<{ stream: ReadableStream; mimetype: string; size?: number } | null> {
|
|
387
|
+
): Promise<{ stream: ReadableStream; mimetype: string; size?: number; attachment?: boolean } | null> {
|
|
362
388
|
const storage = getStorageForCollection(collection, tenant_id);
|
|
363
389
|
const col = getFileCollection(collection, tenant_id);
|
|
364
390
|
const subpath = col?.storage?.path || undefined;
|
|
365
391
|
const stream = await storage.getStream(tenant_id, collection, fileId, filename, subpath);
|
|
366
392
|
if (!stream) return null;
|
|
367
393
|
|
|
368
|
-
|
|
394
|
+
// Faille 10: serve the MIME validated at upload time (content-checked),
|
|
395
|
+
// not re-derived from the extension.
|
|
396
|
+
let mimetype: string | undefined;
|
|
397
|
+
try {
|
|
398
|
+
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
399
|
+
const doc = await rest.db.collection(collection).findOne(
|
|
400
|
+
{ _id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any },
|
|
401
|
+
{ projection: { '_file.mimetype': 1 } },
|
|
402
|
+
);
|
|
403
|
+
mimetype = (doc as any)?._file?.mimetype;
|
|
404
|
+
} catch {}
|
|
405
|
+
|
|
406
|
+
// Fallback: extension-derived MIME only when nothing stored
|
|
407
|
+
mimetype = mimetype || getMimeType(filename);
|
|
408
|
+
const isSvg = mimetype === 'image/svg+xml';
|
|
369
409
|
|
|
370
410
|
if (transform && (transform.width || transform.height || transform.format)) {
|
|
371
411
|
const { data, mimetype: newMime } = await transformImage(stream, transform);
|
|
372
412
|
return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
|
|
373
413
|
}
|
|
374
414
|
|
|
415
|
+
// Faille 4: SVG is served as an attachment (never inline) to prevent stored XSS
|
|
416
|
+
if (isSvg) {
|
|
417
|
+
return { stream, mimetype, attachment: true };
|
|
418
|
+
}
|
|
419
|
+
|
|
375
420
|
return { stream, mimetype };
|
|
376
421
|
}
|
|
377
422
|
|
|
@@ -389,6 +434,44 @@ function getMimeType(filename: string): string {
|
|
|
389
434
|
return map[ext] || 'application/octet-stream';
|
|
390
435
|
}
|
|
391
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Detect a file's real type from its magic bytes.
|
|
439
|
+
* Returns null when unknown. Used to prevent content spoofing (XSS via SVG etc).
|
|
440
|
+
*/
|
|
441
|
+
export function detectMimeFromBuffer(buf: Uint8Array): string | null {
|
|
442
|
+
const head = (offset: number, len: number) =>
|
|
443
|
+
String.fromCharCode(...buf.slice(offset, offset + len));
|
|
444
|
+
const hex = (offset: number, len: number) =>
|
|
445
|
+
Array.from(buf.slice(offset, offset + len)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
446
|
+
|
|
447
|
+
// JPEG: FF D8 FF
|
|
448
|
+
if (hex(0, 3) === 'ffd8ff') return 'image/jpeg';
|
|
449
|
+
// PNG: 89 50 4E 47
|
|
450
|
+
if (hex(0, 4) === '89504e47') return 'image/png';
|
|
451
|
+
// GIF: GIF87a / GIF89a
|
|
452
|
+
if (head(0, 4) === 'GIF8') return 'image/gif';
|
|
453
|
+
// WebP: RIFF....WEBP
|
|
454
|
+
if (head(0, 4) === 'RIFF' && head(8, 4) === 'WEBP') return 'image/webp';
|
|
455
|
+
// AVIF: ftypavif / ftypavis at offset 4
|
|
456
|
+
if (hex(4, 4) === '66747970' && (head(8, 4) === 'avif' || head(8, 4) === 'avis')) return 'image/avif';
|
|
457
|
+
// BMP: BM
|
|
458
|
+
if (head(0, 2) === 'BM') return 'image/bmp';
|
|
459
|
+
// PDF: %PDF
|
|
460
|
+
if (head(0, 4) === '%PDF') return 'application/pdf';
|
|
461
|
+
// ZIP (docx/xlsx/etc): PK\x03\x04
|
|
462
|
+
if (hex(0, 4) === '504b0304') return 'application/zip';
|
|
463
|
+
// SVG: XML declaration or <svg — MUST be served as attachment
|
|
464
|
+
const text = new TextDecoder().decode(buf.subarray(0, 2048));
|
|
465
|
+
if (/^\s*(<\?xml|<!DOCTYPE|<svg)/i.test(text)) return 'image/svg+xml';
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** True if the file content contains executable script (used for SVG hardening) */
|
|
470
|
+
function svgContainsScript(buf: Uint8Array): boolean {
|
|
471
|
+
const text = new TextDecoder().decode(buf.subarray(0, 65536)); // first 64KB is enough
|
|
472
|
+
return /<script|onload\s*=|onerror\s*=|javascript:/i.test(text);
|
|
473
|
+
}
|
|
474
|
+
|
|
392
475
|
export async function handleDelete(
|
|
393
476
|
tenant_id: string,
|
|
394
477
|
collection: string,
|
|
@@ -403,7 +486,7 @@ export async function handleDelete(
|
|
|
403
486
|
try {
|
|
404
487
|
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
405
488
|
const doc = await rest.db.collection(collection).findOne(
|
|
406
|
-
{ _id: ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId },
|
|
489
|
+
{ _id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any },
|
|
407
490
|
{ projection: { '_file.filename': 1 } },
|
|
408
491
|
);
|
|
409
492
|
filename = doc?._file?.filename;
|
|
@@ -426,7 +509,7 @@ export async function handleDelete(
|
|
|
426
509
|
try {
|
|
427
510
|
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
428
511
|
await rest.db.collection(collection).deleteOne({
|
|
429
|
-
_id: ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId,
|
|
512
|
+
_id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any,
|
|
430
513
|
});
|
|
431
514
|
} catch (err: any) {
|
|
432
515
|
console.error('Failed to delete file metadata:', err?.message || err);
|
package/lib/mcp.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Glob } from "bun"
|
|
2
|
+
import { cfg } from "../server/config"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import fs from "fs/promises"
|
|
5
|
+
import type { McpTool, McpResource } from "../types/mcp"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load MCP tools & resources from each tenant's `mcp/` folder:
|
|
9
|
+
* {tenant.dir}/mcp/tools/**\/*.tool.ts → tools
|
|
10
|
+
* {tenant.dir}/mcp/resources/**\/*.resource.ts → resources
|
|
11
|
+
*/
|
|
12
|
+
async function syncMcpTools() {
|
|
13
|
+
try {
|
|
14
|
+
const tools: McpTool[] = []
|
|
15
|
+
const resources: McpResource[] = []
|
|
16
|
+
for (let tenant of cfg.tenants ?? []) {
|
|
17
|
+
const TENANT_PATH = path.join(process.cwd(), tenant.dir)
|
|
18
|
+
const MCP_PATH = path.join(TENANT_PATH, 'mcp')
|
|
19
|
+
let exist = await fs.exists(MCP_PATH)
|
|
20
|
+
if (!exist) continue;
|
|
21
|
+
const isDirectory = await (await fs.stat(MCP_PATH)).isDirectory()
|
|
22
|
+
if (!isDirectory) continue;
|
|
23
|
+
|
|
24
|
+
// tools: mcp/tools/**/*.tool.ts
|
|
25
|
+
const TOOLS_PATH = path.join(MCP_PATH, 'tools')
|
|
26
|
+
if (await fs.exists(TOOLS_PATH)) {
|
|
27
|
+
const toolsGlob = new Glob(path.join(TOOLS_PATH, '**/*.tool.ts'))
|
|
28
|
+
for await (let file of toolsGlob.scan('.')) {
|
|
29
|
+
let module = await import(file)
|
|
30
|
+
if (module?.default?._isMcpTool_ && module?.default?.enabled !== false) {
|
|
31
|
+
tools.push({ ...module?.default, _tenant_: tenant.id })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// resources: mcp/resources/**/*.resource.ts
|
|
37
|
+
const RESOURCES_PATH = path.join(MCP_PATH, 'resources')
|
|
38
|
+
if (await fs.exists(RESOURCES_PATH)) {
|
|
39
|
+
const resourcesGlob = new Glob(path.join(RESOURCES_PATH, '**/*.resource.ts'))
|
|
40
|
+
for await (let file of resourcesGlob.scan('.')) {
|
|
41
|
+
let module = await import(file)
|
|
42
|
+
if (module?.default?._isMcpResource_ && module?.default?.enabled !== false) {
|
|
43
|
+
resources.push({ ...module?.default, _tenant_: tenant.id })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cfg.mcpTools = tools
|
|
50
|
+
cfg.mcpResources = resources
|
|
51
|
+
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
console.error(err?.message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
syncMcpTools
|
|
59
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { cfg } from "../server/config";
|
|
2
|
+
import { useRest } from "../database/rest";
|
|
3
|
+
import { syncTenants } from "../database/tenant";
|
|
4
|
+
import { syncCollections } from "../database/collection";
|
|
5
|
+
import { syncFileCollections } from "../database/file";
|
|
6
|
+
import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage";
|
|
7
|
+
|
|
8
|
+
// ─── Heavy-script worker entrypoint ─────────────────────────────────────────
|
|
9
|
+
// Spawned by runScripts() for scripts marked `heavy: true`.
|
|
10
|
+
//
|
|
11
|
+
// bun run lib/scriptWorker.ts <absolute-script-path>
|
|
12
|
+
//
|
|
13
|
+
// The tenant config (id, dir, database.uri…) is received as JSON on **stdin**
|
|
14
|
+
// (never argv, to avoid leaking credentials in `ps`). The worker rebuilds the
|
|
15
|
+
// minimal app context — tenant DB connection + collection schemas — then runs
|
|
16
|
+
// the script and reports progress via IPC (process.send).
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const scriptPath = process.argv[2];
|
|
20
|
+
if (!scriptPath) {
|
|
21
|
+
console.error('[script-worker] usage: scriptWorker <scriptPath>');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Read the tenant config from stdin (written by the parent)
|
|
26
|
+
let tenant: any;
|
|
27
|
+
try {
|
|
28
|
+
const input = (await new Response(process.stdin).json()) as { tenant?: any };
|
|
29
|
+
tenant = input?.tenant;
|
|
30
|
+
} catch (err: any) {
|
|
31
|
+
console.error('[script-worker] failed to read tenant config from stdin:', err?.message);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
if (!tenant?.id || !tenant?.database?.uri) {
|
|
35
|
+
console.error('[script-worker] missing tenant config (id / database.uri)');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Rebuild the app context, same as boot: connect DB, load collection schemas
|
|
41
|
+
cfg.tenants = [tenant];
|
|
42
|
+
await syncTenants();
|
|
43
|
+
await syncCollections();
|
|
44
|
+
await syncFileCollections();
|
|
45
|
+
} catch (err: any) {
|
|
46
|
+
console.error('[script-worker] failed to bootstrap context:', err?.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const module = await import(scriptPath);
|
|
51
|
+
const script = module?.default;
|
|
52
|
+
if (!script?._isScript_ || !script.enabled) {
|
|
53
|
+
console.error(`[script-worker] '${scriptPath}' is not an enabled script`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rest = new useRest({ tenant_id: tenant.id });
|
|
58
|
+
const progress = (percent: number, info?: any) =>
|
|
59
|
+
process.send?.({ type: 'progress', percent, info });
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await asyncContextStorage.run(new Map(), async () => {
|
|
63
|
+
requestCtxStorage.set('trace', { id: crypto.randomUUID() });
|
|
64
|
+
requestCtxStorage.set('internal', true);
|
|
65
|
+
await script.exec({ rest, progress, tenant });
|
|
66
|
+
});
|
|
67
|
+
process.send?.({ type: 'done' });
|
|
68
|
+
process.exit(0);
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
process.send?.({ type: 'error', message: err?.message });
|
|
71
|
+
console.error('[script-worker] script failed:', err);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main();
|
package/lib/scripts.ts
CHANGED
|
@@ -3,12 +3,70 @@ import { cfg } from "../server/config"
|
|
|
3
3
|
import path from "path"
|
|
4
4
|
import fs from "fs/promises"
|
|
5
5
|
import type { Script } from "../types/scripts"
|
|
6
|
+
import type { Tenant } from "../types/tenant"
|
|
6
7
|
import { useRest } from "../database/rest"
|
|
7
8
|
import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage"
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
// Path of the heavy-script worker entrypoint (spawned as its own Bun process)
|
|
12
|
+
const WORKER_PATH = path.join(import.meta.dir, 'scriptWorker.ts');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run a `heavy` script in a separate Bun process so it never blocks the HTTP
|
|
16
|
+
* server. The worker reconnects the tenant DB and rebuilds collection schemas.
|
|
17
|
+
* Progress is reported back via IPC when the script calls `progress(...)`.
|
|
18
|
+
*/
|
|
19
|
+
async function runHeavyScript(file: string, tenant: Tenant, timeout?: number): Promise<void> {
|
|
20
|
+
const scriptPath = path.resolve(file);
|
|
21
|
+
const proc = Bun.spawn(['bun', 'run', WORKER_PATH, scriptPath], {
|
|
22
|
+
env: { ...process.env },
|
|
23
|
+
stdin: 'pipe',
|
|
24
|
+
stdout: 'inherit',
|
|
25
|
+
stderr: 'inherit',
|
|
26
|
+
ipc: (message: any) => {
|
|
27
|
+
if (message?.type === 'progress') {
|
|
28
|
+
console.log(`[script:${path.basename(file)}] ${message.percent}%`, message.info ?? '');
|
|
29
|
+
} else if (message?.type === 'error') {
|
|
30
|
+
console.error(`[script:${path.basename(file)}]`, message?.message);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Send the serializable tenant config (uri/options only — never client/db
|
|
36
|
+
// Mongo instances, which are not JSON-serializable)
|
|
37
|
+
const payload = JSON.stringify({
|
|
38
|
+
tenant: {
|
|
39
|
+
id: tenant.id,
|
|
40
|
+
name: tenant.name,
|
|
41
|
+
description: tenant.description,
|
|
42
|
+
dir: tenant.dir,
|
|
43
|
+
routes: tenant.routes,
|
|
44
|
+
database: {
|
|
45
|
+
uri: tenant.database?.uri,
|
|
46
|
+
options: tenant.database?.options,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
proc.stdin.write(payload);
|
|
51
|
+
proc.stdin.end();
|
|
52
|
+
|
|
53
|
+
// Optional hard timeout — kills the worker if it exceeds it
|
|
54
|
+
const timer = timeout ? setTimeout(() => {
|
|
55
|
+
console.error(`[script:${path.basename(file)}] timed out after ${timeout}ms — killing`);
|
|
56
|
+
proc.kill();
|
|
57
|
+
}, timeout) : null;
|
|
58
|
+
|
|
59
|
+
const code = await proc.exited;
|
|
60
|
+
if (timer) clearTimeout(timer);
|
|
61
|
+
if (code !== 0) {
|
|
62
|
+
console.error(`[script:${path.basename(file)}] exited with code ${code}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
10
67
|
async function runScripts() {
|
|
11
|
-
|
|
68
|
+
type ScriptEntry = { script: Script; file: string; tenant: Tenant };
|
|
69
|
+
const entries: ScriptEntry[] = [];
|
|
12
70
|
for (let tenant of cfg.tenants ?? []) {
|
|
13
71
|
const TENANT_PATH = path.join(process.cwd(), tenant.dir)
|
|
14
72
|
const SCRIPTS_PATH = path.join(TENANT_PATH, 'scripts')
|
|
@@ -20,28 +78,35 @@ async function runScripts() {
|
|
|
20
78
|
for await (let file of glob.scan('.')) {
|
|
21
79
|
let scriptModule = await import(file)
|
|
22
80
|
if (scriptModule?.default?._isScript_ && scriptModule?.default?.enabled) {
|
|
23
|
-
|
|
24
|
-
...scriptModule?.default,
|
|
25
|
-
rest: new useRest({
|
|
26
|
-
tenant_id: tenant.id,
|
|
27
|
-
})
|
|
28
|
-
})
|
|
81
|
+
entries.push({ script: scriptModule.default, file, tenant })
|
|
29
82
|
}
|
|
30
83
|
}
|
|
31
84
|
}
|
|
32
85
|
}
|
|
33
86
|
|
|
34
|
-
|
|
35
|
-
|
|
87
|
+
const heavyTasks: Promise<void>[] = [];
|
|
88
|
+
for (const { script, file, tenant } of entries) {
|
|
89
|
+
if (script.heavy) {
|
|
90
|
+
// Heavy scripts run in a subprocess — never block the server
|
|
91
|
+
heavyTasks.push(runHeavyScript(file, tenant, script.timeout));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await asyncContextStorage.run(new Map(), async () => {
|
|
36
96
|
requestCtxStorage.set('trace', { id: crypto.randomUUID() })
|
|
37
97
|
requestCtxStorage.set('internal', true)
|
|
38
|
-
|
|
39
98
|
await script.exec({
|
|
40
|
-
rest:
|
|
99
|
+
rest: new useRest({ tenant_id: tenant.id }),
|
|
100
|
+
tenant,
|
|
41
101
|
})
|
|
42
102
|
})
|
|
43
103
|
}
|
|
104
|
+
|
|
105
|
+
// Wait for all heavy subprocesses (they keep running after this resolves)
|
|
106
|
+
if (heavyTasks.length) {
|
|
107
|
+
await Promise.allSettled(heavyTasks);
|
|
108
|
+
}
|
|
44
109
|
}
|
|
45
110
|
export {
|
|
46
111
|
runScripts
|
|
47
|
-
}
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anteros/core",
|
|
3
3
|
"module": "index.ts",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.21",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Anteros Core",
|
|
7
7
|
"private": false,
|
|
@@ -17,16 +17,19 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@colors/colors": "1.6.0",
|
|
20
|
+
"@hono/mcp": "^0.3.1",
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
20
22
|
"@socket.io/bun-engine": "0.1.1",
|
|
21
23
|
"bentocache": "1.6.1",
|
|
22
24
|
"boxen": "^8.0.1",
|
|
23
25
|
"dayjs": "1.11.21",
|
|
24
|
-
"hono": "4.13.
|
|
26
|
+
"hono": "^4.13.1",
|
|
25
27
|
"ioredis": "6.0.0",
|
|
26
28
|
"joi": "18.2.3",
|
|
27
29
|
"jose": "6.2.8",
|
|
28
30
|
"mongodb": "7.5.0",
|
|
29
31
|
"socket.io": "4.8.3",
|
|
30
|
-
"ufo": "1.6.4"
|
|
32
|
+
"ufo": "1.6.4",
|
|
33
|
+
"zod": "^4.4.3"
|
|
31
34
|
}
|
|
32
35
|
}
|
package/probe-cache.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Cache } from "./utils/cache";
|
|
2
|
+
|
|
3
|
+
async function probe() {
|
|
4
|
+
const c = new Cache("memory");
|
|
5
|
+
const v = await c.get({ key: "k" });
|
|
6
|
+
const p = await c.pull({ key: "p" });
|
|
7
|
+
const g = await c.getOrSet({ key: "g", factory: () => 42 });
|
|
8
|
+
const s = await c.set({ key: "a", value: 1 });
|
|
9
|
+
const h = await c.has({ key: "a" });
|
|
10
|
+
const i: unknown = v;
|
|
11
|
+
const j: unknown = p;
|
|
12
|
+
const k: unknown = g;
|
|
13
|
+
console.log(typeof v, typeof p, typeof g, s, h);
|
|
14
|
+
void i; void j; void k;
|
|
15
|
+
}
|
|
16
|
+
void probe;
|