@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.
Files changed (46) hide show
  1. package/README.md +90 -5
  2. package/database/decorator.ts +1 -1
  3. package/database/mongodbadapter.ts +64 -40
  4. package/index.ts +1 -1
  5. package/lib/define.ts +60 -3
  6. package/lib/files.ts +115 -32
  7. package/lib/mcp.ts +59 -0
  8. package/lib/scriptWorker.ts +76 -0
  9. package/lib/scripts.ts +77 -12
  10. package/package.json +6 -3
  11. package/probe-cache.ts +16 -0
  12. package/server/api.ts +35 -28
  13. package/server/boot.ts +78 -16
  14. package/server/cluster.ts +177 -0
  15. package/server/config.ts +0 -1
  16. package/server/env.ts +6 -0
  17. package/server/hono.ts +192 -18
  18. package/server/mcp.ts +232 -0
  19. package/server/metrics.ts +110 -0
  20. package/server/security.ts +13 -9
  21. package/tests/api.test.ts +14 -12
  22. package/tests/cache.test.ts +124 -0
  23. package/tests/crypto.test.ts +101 -0
  24. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  25. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  26. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  27. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  28. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  29. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  30. package/tests/fixtures/metrics-worker.ts +21 -0
  31. package/tests/hono.test.ts +211 -0
  32. package/tests/mcp.test.ts +186 -0
  33. package/tests/metrics-cluster.test.ts +103 -0
  34. package/tests/scripts-heavy.test.ts +49 -0
  35. package/tests/security.test.ts +102 -0
  36. package/tests/ts1011-test.ts +15 -0
  37. package/tests/utils.test.ts +113 -0
  38. package/types/api.d.ts +11 -0
  39. package/types/collection.d.ts +20 -5
  40. package/types/config.d.ts +20 -3
  41. package/types/hook.d.ts +16 -13
  42. package/types/mcp.d.ts +81 -0
  43. package/types/scripts.d.ts +14 -1
  44. package/utils/cache.ts +20 -1
  45. package/utils/crypto.ts +25 -8
  46. 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
- await fs.writeFile(filepath, new Uint8Array(buffer));
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
- // S3 upload via fetch (AWS S3 REST API)
94
- const url = config.endpoint
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 url = config.endpoint
116
- ? `${config.endpoint}/${config.bucket}/${key}`
117
- : `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
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
- const url = config.endpoint
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
- let mimetype = getMimeType(filename);
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
- let scripts: (Script & { rest: InstanceType<typeof useRest> })[] = []
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
- scripts.push({
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
- for (let script of scripts) {
35
- asyncContextStorage.run(new Map(), async () => {
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: script.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.19",
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.0",
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;