@anteros/core 0.0.40 → 0.0.42

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 (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
package/server/api.ts CHANGED
@@ -3,6 +3,7 @@ import { cfg, safePublicConfig } from "./config";
3
3
  import type { ApiOptions } from "../types/api";
4
4
  import { getCollection } from "../database/collection";
5
5
  import { getFileCollection } from "../database/file";
6
+ import { getTenant } from "../database/tenant";
6
7
  import { AppError, fn } from "../lib/error";
7
8
  import * as cookie from "hono/cookie"
8
9
  import { useRest } from "../database/rest";
@@ -16,10 +17,17 @@ import type { ActivityInput } from "../types/activity";
16
17
  import * as os from 'node:os';
17
18
  import { basename } from 'node:path';
18
19
  import { handleUpload, handleServe, handleDelete, type FileResult } from "../lib/files";
19
- const API_PREFIX = '/api/:tenant_id/:collection/:action';
20
- const SERVICE_PREFIX = '/services/:tenant_id/:service/:action';
21
- const UPLOAD_PREFIX = '/upload/:tenant_id/:collection';
22
- const FILES_PREFIX = '/files/:tenant_id/:collection/:file';
20
+ import { getVarsDefinition } from "../database/vars";
21
+ import { endpoints, patterns } from "../lib/endpoints";
22
+ import { getAccessToken, errorResponse, evaluateAccess } from "./access";
23
+
24
+ // Every surface hangs off `/api/:tenant_id` — the patterns live in `lib/endpoints.ts`,
25
+ // next to the builders a client (and the SDK) uses, so a path is written once.
26
+ const API_PREFIX = patterns.collection;
27
+ const SERVICE_PREFIX = patterns.service;
28
+ const VARS_PREFIX = patterns.vars;
29
+ const UPLOAD_PREFIX = patterns.upload;
30
+ const FILES_PREFIX = patterns.file;
23
31
 
24
32
 
25
33
  const ActionsValues = [
@@ -41,26 +49,6 @@ const ActionsValues = [
41
49
 
42
50
  // ─── Helpers ───────────────────────────────────────────────────────────────
43
51
 
44
- function getAccessToken(c: any) {
45
- const t = c.get('token');
46
- return {
47
- value: (t?.value ?? null) as string | null,
48
- decoded: (t?.decoded ?? null) as Record<string, unknown> | null,
49
- provided: (t?.provided ?? false) as boolean,
50
- expired: (t?.expired ?? false) as boolean,
51
- };
52
- }
53
-
54
- function errorResponse(c: any, err: any) {
55
- const isAppError = err instanceof AppError;
56
- const status = isAppError ? Number(err.status) : 500;
57
- return c.json({
58
- message: isAppError ? err.message : 'Internal server error',
59
- code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
60
- meta: isAppError ? err.meta : undefined,
61
- }, status);
62
- }
63
-
64
52
  function stripReadOnlyData(col: any, body: any) {
65
53
  if (col?.api?.readOnlyFields?.length && body?.data) {
66
54
  body.data = func.omit(body.data, col.api.readOnlyFields);
@@ -73,52 +61,9 @@ function stripReadOnlyUpdate(col: any, body: any) {
73
61
  }
74
62
  }
75
63
 
76
- // ─── Access control helpers ──────────────────────────────────────────────────
77
-
78
- async function evaluateAccess(
79
- access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
80
- operation: string,
81
- rest: InstanceType<typeof useRest>,
82
- label: string,
83
- c: any,
84
- ): Promise<void> {
85
- if (!access) {
86
- throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
87
- }
88
-
89
- const hasWildcard = access['*'] !== undefined;
90
- const hasSpecific = access[operation] !== undefined;
91
-
92
- if (!hasWildcard && !hasSpecific) {
93
- throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
94
- }
95
-
96
- const rule = hasSpecific ? access[operation] : access['*'];
97
- if (rule === undefined) return;
98
-
99
- // Boolean `true` → allow without requiring a token
100
- if (typeof rule === 'boolean') {
101
- if (!rule) {
102
- throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
103
- }
104
- return;
105
- }
106
-
107
- // Function → requires a valid token
108
- const accessToken = getAccessToken(c);
109
-
110
- if (accessToken.expired) {
111
- throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
112
- }
113
- if (!accessToken.value) {
114
- throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
115
- }
116
-
117
- const allowed = await (rule as Function)({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
118
- if (!allowed) {
119
- throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
120
- }
121
- }
64
+ // ─── Access control ─────────────────────────────────────────────────────────
65
+ // `evaluateAccess` / `errorResponse` / `getAccessToken` live in `./access`
66
+ // (shared with the agents API).
122
67
 
123
68
  async function checkFileAccess(
124
69
  access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
@@ -346,15 +291,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
346
291
  }
347
292
 
348
293
  // ── Access control ──
349
- // Faille 7: single implementation via evaluateAccess (checks expired/forged tokens)
294
+ // Flaw 7: single implementation via evaluateAccess (checks expired/forged tokens)
350
295
  await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c);
351
296
 
352
297
  // ── Execute action ──
353
298
  if (col.actions?.[action]) {
354
- response = await col.actions[action]({
355
- rest, data: body?.data, error: fn.error,
356
- io, jwt: func.jwt, token: accessToken,
357
- });
299
+ // Delegated to `rest.runAction` so the invocation is audited once,
300
+ // with the same context (`rest`, `io`, `data`, `token`, …)
301
+ response = await rest.runAction(collection, action, body?.data);
358
302
  } else {
359
303
  const handler = crudHandlers[action];
360
304
  if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
@@ -382,6 +326,97 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
382
326
  }
383
327
  });
384
328
 
329
+ // ─── Vars API ───────────────────────────────────────────────────────
330
+ app.post(VARS_PREFIX, async (c) => {
331
+ let rest: InstanceType<typeof useRest> | undefined;
332
+ let tenant_id = '';
333
+ let action = '';
334
+ let ns = '';
335
+ let body: any;
336
+ const logStart = Date.now();
337
+ try {
338
+ const ContentType = c.req.header('Content-Type');
339
+ if (ContentType?.includes('application/json')) {
340
+ try { body = await c.req.json(); } catch {
341
+ throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
342
+ }
343
+ } else {
344
+ body = {};
345
+ }
346
+
347
+ const params = c.req.param() as { tenant_id: string; action: string };
348
+ tenant_id = params.tenant_id;
349
+ action = params.action;
350
+ ns = body?.ns;
351
+
352
+ if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
353
+ if (!action) throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
354
+ if (!ns) throw new AppError('Variables namespace is required', { status: 400, code: 'NAMESPACE_REQUIRED' });
355
+ if (!getTenant(tenant_id)) throw new AppError('Tenant not found', { status: 400, code: 'TENANT_NOT_FOUND' });
356
+
357
+ requestCtxStorage.set('tenant_id', tenant_id);
358
+
359
+ // Only namespaces declared with `define.Vars` are reachable, and their
360
+ // `api.access` gates every action (no rules = denied).
361
+ const definition = getVarsDefinition(tenant_id, ns);
362
+ if (!definition) throw new AppError(`Variables namespace '${ns}' not found`, { status: 400, code: 'NAMESPACE_NOT_FOUND' });
363
+
364
+ rest = new useRest({ internal: false, tenant_id });
365
+ await evaluateAccess(definition.api?.access as any, action, rest, `vars:${ns}`, c);
366
+
367
+ const vars = rest.vars;
368
+ const { key, value, meta, ttl, scope, where, entries, by } = body ?? {};
369
+ let response: any;
370
+
371
+ switch (action) {
372
+ case 'set': await vars.set(ns, key, value, { meta, ttl, scope }); response = { ok: true }; break;
373
+ case 'setMany': await vars.setMany(ns, entries ?? {}, { meta, ttl, scope }); response = { ok: true }; break;
374
+ case 'get': response = { value: await vars.get(ns, key, { scope }) }; break;
375
+ case 'entry': response = { entry: await vars.entry(ns, key, { scope }) }; break;
376
+ case 'entries': response = { entries: await vars.entries(ns, { scope, where }) }; break;
377
+ case 'all': response = { vars: await vars.all(ns, { scope, where }) }; break;
378
+ case 'del': response = { ok: await vars.del(ns, key, { scope }) }; break;
379
+ case 'has': response = { exists: await vars.has(ns, key, { scope }) }; break;
380
+ case 'incr': response = { value: await vars.incr(ns, key, by, { scope, ttl }) }; break;
381
+ case 'expire': response = { ok: await vars.expire(ns, key, ttl, { scope }) }; break;
382
+ case 'clear': response = { deleted: await vars.clear(ns, { scope }) }; break;
383
+ default: throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
384
+ }
385
+
386
+ // Audit — never store `value` / `meta` (they may hold secrets).
387
+ const accessToken = getAccessToken(c);
388
+ await logActivity({
389
+ rest,
390
+ tenant_id,
391
+ action,
392
+ collection: `_vars_:${ns}`,
393
+ status: 'success',
394
+ input: { ns, key, scope, ttl },
395
+ result: ['get', 'all', 'entry', 'entries'].includes(action) ? response : undefined,
396
+ duration: Date.now() - logStart,
397
+ token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
398
+ });
399
+ return c.json(response);
400
+ } catch (err: any) {
401
+ if (cfg?.debug) console.error(err);
402
+ if (rest && tenant_id && ns) {
403
+ const accessToken = getAccessToken(c);
404
+ await logActivity({
405
+ rest,
406
+ tenant_id,
407
+ action,
408
+ collection: `_vars_:${ns}`,
409
+ status: 'error',
410
+ input: { ns },
411
+ error: { message: err?.message, code: err?.code || 'INTERNAL_VARS_ERROR' },
412
+ duration: Date.now() - logStart,
413
+ token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
414
+ }).catch(() => {});
415
+ }
416
+ return errorResponse(c, err);
417
+ }
418
+ });
419
+
385
420
  // ─── Service API ────────────────────────────────────────────────────
386
421
  app.post(SERVICE_PREFIX, async (c) => {
387
422
  try {
@@ -415,21 +450,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
415
450
  const serviceAccess = serviceInstance.api?.access as Record<string, boolean | Function> | undefined;
416
451
  await evaluateAccess(serviceAccess as any, action, rest, action, c);
417
452
 
418
- // Re-read accessToken for the action handler
419
- const accessToken = getAccessToken(c);
420
-
421
- const logStart = Date.now();
422
- try {
423
- const response = await serviceInstance.actions[action]({
424
- data: body?.data, error: fn.error, io, jwt: func.jwt, token: accessToken, rest,
425
- });
426
-
427
- await logActivity({ rest, tenant_id, action, collection: service, status: 'success', input: body?.data, result: response, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } });
428
- return c.json(response);
429
- } catch (err: any) {
430
- await logActivity({ rest, tenant_id, action, collection: service, status: 'error', input: body?.data, error: { message: err?.message, code: err?.code || 'INTERNAL_SERVICE_ERROR' }, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } }).catch(() => {});
431
- throw err;
432
- }
453
+ // No `logActivity` here: the call is delegated to `rest.runService()`, which
454
+ // audits it once (`action: 'runService'`, `internal: false`, params only)
455
+ const response = await rest.runService(service, action, body?.data);
456
+ return c.json(response);
433
457
  } catch (err: any) {
434
458
  if (cfg?.debug) console.error(err)
435
459
  return errorResponse(c, err);
@@ -469,12 +493,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
469
493
  });
470
494
  }
471
495
 
472
- const formData = await c.req.parseBody({ all: true }); // all: true → every value is an array (several files can share the same field name)
496
+ const formData = await c.req.parseBody({ all: true }); // repeated fields → arrays; a single occurrence stays a single value
473
497
 
474
498
  // Collect files from both accepted field names (single or multiple)
475
499
  const files: File[] = [];
476
500
  for (const key of ['file', 'upload']) {
477
- for (const value of formData[key] ?? []) {
501
+ const raw = formData[key];
502
+ const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
503
+ for (const value of values) {
478
504
  if (value instanceof File) files.push(value);
479
505
  }
480
506
  }
@@ -521,7 +547,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
521
547
  }
522
548
  await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
523
549
 
524
- // Faille 18: validate the RAW path before basename (checks were dead code)
550
+ // Flaw 18: validate the RAW path before basename (checks were dead code)
525
551
  const rawFile = c.req.param('file') as string;
526
552
  if (!rawFile || rawFile.startsWith('.') || rawFile.includes('..') || rawFile.includes('/') || rawFile.includes('\\')) {
527
553
  throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
@@ -547,7 +573,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
547
573
  c.header('Content-Type', result.mimetype);
548
574
  if (result.size) c.header('Content-Length', String(result.size));
549
575
  c.header('Cache-Control', 'public, max-age=31536000, immutable');
550
- // Faille 4: SVG served as attachment (no inline execution)
576
+ // Flaw 4: SVG served as attachment (no inline execution)
551
577
  if (result.attachment) {
552
578
  c.header('Content-Disposition', `attachment; filename="${filename}"`);
553
579
  }
@@ -590,7 +616,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
590
616
  // ─── Public config ──────────────────────────────────────────────────
591
617
  app.get('/_dnax/config/:tenant_id', (c) => {
592
618
  const t = c.get('token');
593
- // Faille 6: require a VERIFIED, non-expired token (not just any provided value)
619
+ // Flaw 6: require a VERIFIED, non-expired token (not just any provided value)
594
620
  if (!t?.decoded || t?.expired || !t?.provided) {
595
621
  return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
596
622
  }
package/server/boot.ts CHANGED
@@ -1,5 +1,7 @@
1
1
 
2
2
  import { createApp, collectHeaders } from './hono'
3
+ import { parseBootFlags, resolveCapabilities, conflictingBootFlags, offCapabilities, capabilitiesLabel, FULL_CAPABILITIES } from './flags'
4
+ import type { BootCapabilities } from './flags'
3
5
  import type { ServerConfig } from '../types/config'
4
6
  import dayjs from 'dayjs';
5
7
  import pkg from '../package.json';
@@ -9,6 +11,10 @@ import { cfg, formatConfig } from './config' // import the config
9
11
  import { syncTenants } from '../database/tenant'
10
12
  import { syncCollections } from '../database/collection'
11
13
  import { syncFileCollections } from '../database/file'
14
+ import { syncVars } from '../database/vars'
15
+ import { startReplication, stopReplication } from '../database/replication'
16
+ import { loadLifecycles, runBeforeBoot, runAfterBoot, runOnDestroy } from '../lib/lifecycle'
17
+ import { workflowStats } from '../lib/workflow'
12
18
 
13
19
  import { loadRoutes } from '../lib/routes'
14
20
  import { runScripts } from '../lib/scripts'
@@ -17,6 +23,7 @@ import { loadServices } from '../lib/services'
17
23
  import { loadSockets } from '../lib/sockets'
18
24
  import { syncWorkflows } from '../lib/workflow'
19
25
  import { syncMcpTools } from '../lib/mcp'
26
+ import { syncAgents, agentsStats } from '../lib/agents'
20
27
  import { loadTenantsMiddlewares } from '../lib/middleware'
21
28
  import crypto from 'node:crypto';
22
29
  import os from 'node:os';
@@ -28,6 +35,88 @@ import { logger } from '../utils/logger';
28
35
  type BootAppOptions = ServerConfig & {
29
36
 
30
37
  }
38
+
39
+ /**
40
+ * Console banner — name / PID / role, env, reusePort, cache, replication.
41
+ * `url` is omitted in replication-only mode (nothing is listening).
42
+ */
43
+ function renderBanner(opts: {
44
+ name: string;
45
+ role: string;
46
+ env: string;
47
+ reusePort: boolean;
48
+ url?: string;
49
+ caps?: BootCapabilities;
50
+ }): string {
51
+ const { name, role, env, reusePort, url } = opts;
52
+ const caps = opts.caps ?? FULL_CAPABILITIES;
53
+ const off = offCapabilities(caps);
54
+
55
+ let box = '';
56
+ box += `${name}`.gray.underline + ` (PID: ${process.pid}) — ${role}\n\n`
57
+ box += `Env: ${env || 'dev'}`.green.bold + '\n'
58
+ box += `reusePort: ${reusePort ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
59
+
60
+ // DB query cache status (server.cache) — driver: memory | filesystem | redis
61
+ const cacheCfg = cfg.server.cache;
62
+ // any: @colors/colors types chained styles (.red.bold) as Color functions
63
+ let cacheInfo: any = 'Off'.red.bold;
64
+ if (cacheCfg?.enabled) {
65
+ const driver = cacheCfg.driver ?? 'memory';
66
+ const detail = driver === 'filesystem'
67
+ ? ` ${cacheCfg.directory ?? './.cache'}`
68
+ : driver === 'redis'
69
+ ? ` ${cacheCfg.redis?.host ?? 'localhost'}${cacheCfg.redis?.port ? `:${cacheCfg.redis.port}` : ''}`
70
+ : '';
71
+ cacheInfo = `On`.green.bold + ` (${driver}${detail})`;
72
+ }
73
+ box += `Cache: ${cacheInfo}`.gray.bold + '\n'
74
+
75
+ // Replication status — tenants with active replication + their destinations
76
+ const replTenants = (cfg.tenants ?? []).filter(
77
+ (t) => t.replication?.enabled !== false && (t.replication?.destinations?.length ?? 0) > 0,
78
+ );
79
+ let replicationInfo: any = 'Off'.red.bold;
80
+ if (!caps.replication) {
81
+ // The engine is never started, whatever the tenants declare
82
+ replicationInfo = 'Off'.red.bold + ` (${capabilitiesLabel(caps)})`.gray;
83
+ } else if (replTenants.length) {
84
+ const parts = replTenants.map((t) => {
85
+ const dests = (t.replication?.destinations ?? []).map((d) => d.id).join(', ');
86
+ return `${t.id} → ${dests}`;
87
+ });
88
+ replicationInfo = `On`.green.bold + ` (${parts.join('; ')})`;
89
+ }
90
+ box += `Replication: ${replicationInfo}`.gray.bold + '\n'
91
+
92
+ // Workflows loaded from `{tenant.dir}/workflows/**/*.workflow.ts`
93
+ const wf = workflowStats();
94
+ box += `Workflows: ${wf.total ? `${wf.total}`.green.bold + ` (${wf.tenants.join(', ')})`.gray : 'none'.gray}\n`.gray.bold
95
+
96
+ // Agents loaded from `{tenant.dir}/agents/**/*.agent.ts`
97
+ const ag = agentsStats();
98
+ box += `Agents: ${ag.total ? `${ag.total}`.green.bold + ` (${ag.tenants.join(', ')})`.gray : 'none'.gray}\n`.gray.bold
99
+
100
+ // Only shown when something is disabled — the default banner stays compact
101
+ if (off.length) {
102
+ box += `Mode: ${capabilitiesLabel(caps)}`.gray.bold + ` (${off.join(', ')} off)`.yellow + '\n';
103
+ }
104
+
105
+ box += url ? `Url: ${url}`.gray.bold : `Url: none — no API`.yellow.bold;
106
+ box += '\n\n';
107
+ box += `Last boot: 🔄 ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`.gray;
108
+
109
+ return boxen(box, {
110
+ title: ` @anteros/core ${pkg.version}`,
111
+ padding: 1,
112
+ float: 'left',
113
+ borderColor: 'gray',
114
+ titleAlignment: 'center',
115
+ borderStyle: 'double',
116
+ textAlignment: 'left',
117
+ dimBorder: true
118
+ })
119
+ }
31
120
  async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
32
121
 
33
122
  try {
@@ -36,6 +125,15 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
36
125
  //******************************* */
37
126
  options = formatConfig(options);
38
127
 
128
+ // Boot capabilities — `--replication-only`, `--no-replication`, `--api-only`,
129
+ // `--no-scripts`, `--no-sockets` (CLI, wins) on top of `server.mode`.
130
+ const bootFlags = parseBootFlags();
131
+ const caps = resolveCapabilities(bootFlags, cfg.server?.mode);
132
+ cfg.server.mode = capabilitiesLabel(caps);
133
+ if (conflictingBootFlags(bootFlags)) {
134
+ console.log('⚠ --replication-only conflicts with --no-replication/--api-only — running replication-only'.yellow);
135
+ }
136
+
39
137
  // Logging: configure from cfg.server.logging (console + file, e.g. logs/anteros.log)
40
138
  logger.configure(cfg.server.logging);
41
139
  // File-only: the boxen banner already shows name / PID / URL / env on the console
@@ -48,14 +146,78 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
48
146
  await syncTenants(); // sync tenants and connect to database
49
147
  await syncCollections(); // sync collections and create collections on database
50
148
  await syncFileCollections(); // sync file collections
51
- await loadServices(); // load services
52
- await loadSockets(); // load websocket handlers
53
- await syncWorkflows();
54
- await syncMcpTools(); // load MCP tools per tenant (mcp/**/*.tool.ts)
55
- await loadTenantsMiddlewares();
56
- loadRoutes(); // load routes
149
+ await syncVars(); // load define.Vars + seed default variables (`_vars_` replication filters on them)
150
+
151
+ // Loaders only needed when an API is served — skipped when the API is off
152
+ if (caps.api) {
153
+ await loadServices(); // load services
154
+ if (caps.sockets) await loadSockets(); // load websocket handlers
155
+ await syncWorkflows();
156
+ await syncMcpTools(); // load MCP tools per tenant (mcp/**/*.tool.ts)
157
+ await syncAgents(); // load agents per tenant (agents/**/*.agent.ts)
158
+ await loadTenantsMiddlewares();
159
+ await loadRoutes(); // load routes
160
+ }
161
+
162
+ await loadLifecycles(); // load {tenant.dir}/lifecycle.ts
163
+ await runBeforeBoot(); // blocking lifecycle hooks (migrations, seeding…) — fail-fast
164
+ if (caps.replication) {
165
+ await startReplication(); // connect replication destinations and schedule
166
+ } else {
167
+ logger.file('Replication engine disabled', { serverMode: cfg.server.mode });
168
+ }
57
169
  //******************************* */
58
170
 
171
+ // Uncaught errors → log file (MongoDB-style), then exit
172
+ process.on('uncaughtException', (err) => {
173
+ logger.error('Uncaught exception', { message: err?.message, stack: err?.stack });
174
+ process.exit(1);
175
+ });
176
+ process.on('unhandledRejection', (reason: any) => {
177
+ logger.error('Unhandled rejection', { message: reason?.message ?? String(reason), stack: reason?.stack });
178
+ });
179
+
180
+ // ── NO API: replication-only process ──
181
+ if (!caps.api) {
182
+ const NAME = cfg.server.name || process.env.APP_NAME || 'SERVER';
183
+ const env: string = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
184
+
185
+ // Nothing else keeps the event loop alive — replication timers are unref'd
186
+ const keepAlive = setInterval(() => { }, 60 * 60 * 1000);
187
+
188
+ const shutdownReplicationOnly = async (signal: string) => {
189
+ console.log(`\n${signal} received — stopping replication…`.gray);
190
+ clearInterval(keepAlive);
191
+ try { await runOnDestroy(signal as 'SIGINT' | 'SIGTERM'); } catch {}
192
+ try { await stopReplication(); } catch {}
193
+ for (const tenant of cfg.tenants ?? []) {
194
+ try { tenant.database?.client?.close(); } catch {}
195
+ }
196
+ process.exit(0);
197
+ };
198
+ process.once('SIGINT', () => shutdownReplicationOnly('SIGINT'));
199
+ process.once('SIGTERM', () => shutdownReplicationOnly('SIGTERM'));
200
+
201
+ console.log(renderBanner({
202
+ name: NAME,
203
+ role: 'replication-only',
204
+ env,
205
+ reusePort: false,
206
+ caps,
207
+ }));
208
+
209
+ logger.file('Replication-only process started', {
210
+ name: NAME,
211
+ pid: process.pid,
212
+ role: 'replication-only',
213
+ env,
214
+ tenants: cfg.tenants?.map((t) => t.id),
215
+ logFile: logger.filePath || undefined,
216
+ });
217
+
218
+ return null;
219
+ }
220
+
59
221
 
60
222
  // JWT_SECRET check: auto-generate if missing and auth is enabled
61
223
  const hasAuth = cfg.collections?.some((c: any) => c.api?.auth?.enabled);
@@ -117,12 +279,17 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
117
279
 
118
280
  console.log(`[cluster] Aggregated /health → http://localhost:${metricsPort}/health`.gray);
119
281
 
282
+ // Master owns the boot: run the tenant lifecycles once here (workers skip).
283
+ await runAfterBoot(metricsServer, io);
284
+
120
285
  // Graceful shutdown: stop workers + metrics server so a restart
121
286
  // (e.g. bun --watch) never leaves the port occupied
122
- const shutdownCluster = (signal: string) => {
287
+ const shutdownCluster = async (signal: string) => {
123
288
  console.log(`\n${signal} received — shutting down workers…`.gray);
124
289
  try { master.shutdown(); } catch {}
125
290
  try { metricsServer.stop(true); } catch {}
291
+ try { await runOnDestroy(signal as 'SIGINT' | 'SIGTERM'); } catch {}
292
+ try { await stopReplication(); } catch {}
126
293
  for (const tenant of cfg.tenants ?? []) {
127
294
  try { tenant.database?.client?.close(); } catch {}
128
295
  }
@@ -170,7 +337,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
170
337
  return res;
171
338
  });
172
339
 
173
- if (url.pathname === "/socket.io/") {
340
+ if (caps.sockets && url.pathname === "/socket.io/") {
174
341
  const start = performance.now();
175
342
  return respond(engineIo.handleRequest(req, server)).then((res) => {
176
343
  // Access log for the Socket.IO upgrade path (handled outside Hono) —
@@ -208,6 +375,8 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
208
375
  }
209
376
  return respond(app.fetch(req, server));
210
377
  },
378
+ // Sockets are disabled by not serving `/socket.io/` (below): without an
379
+ // explicit `server.upgrade()` call no client can ever open a websocket.
211
380
  websocket: websocket,
212
381
  maxRequestBodySize: 1024 * 1024 * 100, // 100MB
213
382
  })
@@ -215,11 +384,16 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
215
384
  // Register server for built-in metrics (GET /health)
216
385
  registerMetrics(server);
217
386
 
387
+ // The server is listening — run the tenant lifecycles (standalone only).
388
+ if (!isWorker) await runAfterBoot(server, io);
389
+
218
390
  // Graceful shutdown: release the port & DB connections so restarts
219
391
  // (e.g. bun --watch) never hit a zombie / EADDRINUSE
220
- const shutdownServer = (signal: string) => {
392
+ const shutdownServer = async (signal: string) => {
221
393
  console.log(`\n${signal} received — shutting down…`.gray);
222
394
  try { server.stop(true); } catch {}
395
+ try { await runOnDestroy(signal as 'SIGINT' | 'SIGTERM'); } catch {}
396
+ try { await stopReplication(); } catch {}
223
397
  for (const tenant of cfg.tenants ?? []) {
224
398
  try { tenant.database?.client?.close(); } catch {}
225
399
  }
@@ -241,47 +415,25 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
241
415
  }, 5000);
242
416
  }
243
417
 
244
- let box = '';
245
418
  const role = isWorker ? 'worker' : (reusePort ? 'master' : 'standalone');
246
- box += `${NAME}`.gray.underline + ` (PID: ${process.pid}) — ${role}\n\n`
247
- box += `Env: ${env || 'dev'}`.green.bold + '\n'
248
- box += `reusePort: ${(reusePort || isWorker) ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
249
-
250
- // DB query cache status (server.cache) — driver: memory | filesystem | redis
251
- const cacheCfg = cfg.server.cache;
252
- // any: @colors/colors types chained styles (.red.bold) as Color functions
253
- let cacheInfo: any = 'Off'.red.bold;
254
- if (cacheCfg?.enabled) {
255
- const driver = cacheCfg.driver ?? 'memory';
256
- const detail = driver === 'filesystem'
257
- ? ` ${cacheCfg.directory ?? './.cache'}`
258
- : driver === 'redis'
259
- ? ` ${cacheCfg.redis?.host ?? 'localhost'}${cacheCfg.redis?.port ? `:${cacheCfg.redis.port}` : ''}`
260
- : '';
261
- cacheInfo = `On`.green.bold + ` (${driver}${detail})`;
262
- }
263
- box += `Cache: ${cacheInfo}`.gray.bold + '\n'
264
- box += `Url: http://localhost:${PORT}`.gray.bold;
265
- box += '\n\n';
266
- box += `Last boot: 🔄 ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`.gray;
267
-
268
- console.log(boxen(box, {
269
- title: ` @anteros/core ${pkg.version}`,
270
- padding: 1,
271
- float: 'left',
272
- borderColor: 'gray',
273
- titleAlignment: 'center',
274
- borderStyle: 'double',
275
- textAlignment: 'left',
276
- dimBorder: true
419
+
420
+ console.log(renderBanner({
421
+ name: NAME,
422
+ role,
423
+ env,
424
+ reusePort: reusePort || isWorker,
425
+ url: `http://localhost:${PORT}`,
426
+ caps,
277
427
  }))
278
428
 
279
429
 
280
430
  // After boot and App ready.
281
- setTimeout(() => {
282
- runScripts().catch(); // run scripts
431
+ if (caps.scripts) {
432
+ setTimeout(() => {
433
+ runScripts().catch(); // run scripts
283
434
 
284
- }, 150);
435
+ }, 150);
436
+ }
285
437
 
286
438
  // Boot event — recorded in the log file only (the console banner already
287
439
  // shows name / PID / role / URL, so this line would be redundant on screen)
@@ -294,15 +446,6 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
294
446
  logFile: logger.filePath || undefined,
295
447
  });
296
448
 
297
- // Uncaught errors → log file (MongoDB-style), then exit
298
- process.on('uncaughtException', (err) => {
299
- logger.error('Uncaught exception', { message: err?.message, stack: err?.stack });
300
- process.exit(1);
301
- });
302
- process.on('unhandledRejection', (reason: any) => {
303
- logger.error('Unhandled rejection', { message: reason?.message ?? String(reason), stack: reason?.stack });
304
- });
305
-
306
449
  return server;
307
450
  } catch (err: any) {
308
451
  logger.error('Failed to boot server', cfg.debug ? { error: err, message: err?.message } : { message: err?.message });