@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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @dnax/core
1
+ # @anteros/core
2
2
 
3
3
  Server core for **multi-tenant** apps: generic **MongoDB** REST API, **Joi** validation, hooks, JWT auth, custom routes, and boot scripts. Built for **Bun** with **Hono** and **Socket.io**.
4
4
 
@@ -11,7 +11,7 @@ Server core for **multi-tenant** apps: generic **MongoDB** REST API, **Joi** val
11
11
  ## Installation
12
12
 
13
13
  ```bash
14
- bun add @dnax/core@0.0.0-rc.13
14
+ bun add @anteros/core@latest
15
15
  ```
16
16
 
17
17
  ## Quick start
@@ -19,10 +19,9 @@ bun add @dnax/core@0.0.0-rc.13
19
19
  Typical entrypoint in your app (working directory = project root that contains tenant folders):
20
20
 
21
21
  ```ts
22
- import { app, define } from "@dnax/core";
22
+ import { app, define } from "@anteros/core";
23
23
 
24
24
  await app.boot({
25
- clusterMode: false,
26
25
  server: {
27
26
  port: 5000,
28
27
  jwt: {
@@ -57,6 +56,7 @@ On boot, the server syncs tenants, loads collections (`*.model.ts`), registers r
57
56
  | `app` | `{ boot }` — starts the application |
58
57
  | `useRest` | REST client / per-tenant Mongo access (programmatic use or in handlers) |
59
58
  | `v` | **Joi** (re-export), for schemas in models |
59
+ | `cache` | Native caching: `useMemoryCache`, `useFilesystemCache`, `useRedisCache` (Bun.Redis) |
60
60
  | `utils` | Internal utilities exposed by the package |
61
61
 
62
62
  ## Folder layout (per tenant)
@@ -116,11 +116,96 @@ Per collection, `api.access` allows or denies each action (`boolean` or async fu
116
116
 
117
117
  When `api.auth.enabled` is set, define `onLogin` / `onLogout` on the model. The JWT secret is read from `server.jwt.secret` or the **`JWT_SECRET`** environment variable.
118
118
 
119
+ ## Health & metrics
120
+
121
+ The server exposes a built-in **`GET /health`** endpoint (zero dependency) reporting live process metrics:
122
+
123
+ ```json
124
+ {
125
+ "pid": 10047,
126
+ "startedAt": 1720000000000,
127
+ "uptime": 3600,
128
+ "env": "production",
129
+ "connections": 0,
130
+ "pendingRequests": 3,
131
+ "pendingWebSockets": 7,
132
+ "requests": {
133
+ "total": 1523,
134
+ "active": 3,
135
+ "errors": 2,
136
+ "byMethod": { "GET": 1200, "POST": 323 },
137
+ "byStatus": { "200": 1500, "201": 21, "500": 2 }
138
+ },
139
+ "memory": { "rss": 13778944, "heapUsed": 112384, "heapTotal": 521216, "external": 10624 },
140
+ "cpu": { "percent": 12.5, "cores": 8, "user": 120000, "system": 80000 }
141
+ }
142
+ ```
143
+
144
+ | Field | Meaning |
145
+ |-------|---------|
146
+ | `pid` / `uptime` | Process id / seconds since boot |
147
+ | `requests.total` / `requests.errors` | Total requests served / responses ≥ 500 |
148
+ | `requests.byMethod` / `requests.byStatus` | Counters split by HTTP method / status |
149
+ | `memory` | V8 heap + RSS usage (bytes) |
150
+ | `pendingRequests` | Requests currently being processed |
151
+ | `pendingWebSockets` | Active WebSocket connections |
152
+ | `cpu.percent` | % of a single core over the last sample window |
153
+
154
+ With `reusePort`, each process exposes its **own** metrics — useful to see per-worker load.
155
+
156
+ ## Cluster (reusePort + multiple processes)
157
+
158
+ Set `server.reusePort: true` to enable Bun's `SO_REUSEPORT`. The framework then acts as a **master**:
159
+
160
+ - Spawns N workers (`server.workers`, default = CPU count)
161
+ - Each worker serves the main port (the OS round-robins connections)
162
+ - Workers report metrics to the master **via IPC** every 5s
163
+ - The master **restarts any worker that crashes** (auto-respawn)
164
+ - The master exposes an **aggregated `/health`** on `server.metricsPort` (default: `port + 1`)
165
+
166
+ ```typescript
167
+ await app.boot({
168
+ server: {
169
+ port: 5000,
170
+ reusePort: true, // cluster mode
171
+ workers: 4, // optional, default = CPU count
172
+ metricsPort: 5001, // optional, default = port + 1
173
+ },
174
+ // ...
175
+ });
176
+ ```
177
+
178
+ ```bash
179
+ curl http://localhost:5001/health # aggregated across all workers
180
+ ```
181
+
182
+ ```json
183
+ {
184
+ "status": "ok",
185
+ "master": { "pid": 1000 },
186
+ "workers": 4,
187
+ "uptime": { "min": 12, "max": 15 },
188
+ "requests": {
189
+ "total": 1523,
190
+ "active": 11,
191
+ "errors": 2,
192
+ "perSecond": 101,
193
+ "byMethod": { "GET": 1200, "POST": 323 },
194
+ "byStatus": { "200": 1500, "201": 21, "500": 2 }
195
+ },
196
+ "memory": { "rss": 54067200, "heapUsed": 589294 },
197
+ "cpu": { "percent": 25.5, "cores": 8 },
198
+ "pids": [1001, 1002, 1003, 1004]
199
+ }
200
+ ```
201
+
202
+ > ⚠️ Each process has its own memory — use **Redis** for shared cache / Socket.io state.
203
+
119
204
  ## Server configuration
120
205
 
121
206
  Common options when calling `app.boot`:
122
207
 
123
- - `clusterMode` — Bun `reusePort` for multiple workers
208
+ - `server.reusePort` — Bun `SO_REUSEPORT` (share the port across multiple processes)
124
209
  - `server.port`, `server.name`
125
210
  - `server.cors` — origins, credentials, headers, and methods
126
211
  - `server.ipRestriction` — allow/deny lists (Hono)
@@ -10,7 +10,7 @@ export function CheckIfCollectionExists(): any {
10
10
  return descriptor;
11
11
  }
12
12
  const originalMethod = descriptor.value;
13
- descriptor.value = async function (...args: any[]) {
13
+ descriptor.value = async function (this: any, ...args: any[]) {
14
14
  let collection = args[0];
15
15
  let col = getCollection(collection, this.tenant_id)
16
16
  if (!col) {
@@ -42,18 +42,22 @@ class MongoRest {
42
42
  if (options.session) {
43
43
  this.session = options.session;
44
44
  }
45
- let tenant = getTenant(options?.tenant_id ?? '-')
46
- if (options.tenant_id && tenant) {
45
+ // Resolve tenant: explicit tenant_id, else from request context
46
+ let tenantId = options.tenant_id;
47
+ if (!tenantId) {
48
+ tenantId = requestCtxStorage.get<string>('tenant_id');
49
+ }
50
+ let tenant = getTenant(tenantId ?? '-')
51
+ if (tenant) {
47
52
  this.#tenant = tenant;
48
53
  this.tenant_id = tenant.id;
49
54
  this.db = tenant.database.db as Db;
50
55
  this.client = tenant.database.client as MongoClient;
51
56
  }
52
-
53
57
  }
54
58
 
55
59
 
56
- private _validate(options: { collection: string, action?: ActionsApiList, data?: any, update?: UpdateFilter<any> }) {
60
+ private _validate(options: { collection: string, action?: ActionsApiList, data?: any, update?: UpdateFilter<any> }): any {
57
61
  let col = getCollection(options.collection, this.#tenant.id)
58
62
  let schema = col?._schema_
59
63
  let partialSchema = col?._schemaPartial_
@@ -77,6 +81,7 @@ class MongoRest {
77
81
 
78
82
  if (options?.data) {
79
83
  if (options.action == 'insertMany') {
84
+ const sanitized: any[] = [];
80
85
  for (let i = 0; i < options.data.length; i++) {
81
86
  validationResult = schema.validate(options.data[i], { allowUnknown: false })
82
87
  if (validationResult?.error) {
@@ -85,9 +90,9 @@ class MongoRest {
85
90
  status: 400
86
91
  })
87
92
  }
88
- // Apply Joi defaults (e.g., .default() values) back to data
89
- options.data[i] = validationResult.value
93
+ sanitized.push(validationResult.value)
90
94
  }
95
+ return sanitized;
91
96
  }
92
97
 
93
98
  if (options.action == 'insertOne') {
@@ -98,8 +103,8 @@ class MongoRest {
98
103
  status: 400
99
104
  })
100
105
  }
101
- // Persist Joi defaults back to data
102
- if (validationResult.value) options.data = validationResult.value
106
+ // Return sanitized value (Joi defaults applied, unknown keys handled)
107
+ return validationResult.value;
103
108
  }
104
109
  }
105
110
 
@@ -117,22 +122,38 @@ class MongoRest {
117
122
  validationResult = partialSchema.validate(options.update.$setOnInsert, { allowUnknown: false })
118
123
  }
119
124
 
125
+ // Faille 9: updateOne/updateMany — unflatten dot-notation ("address.zip") before Joi validation
126
+ if ((options.action == 'updateOne' || options.action == 'updateMany') && options.update?.$set) {
127
+ const unflattened = func.unflattenKeys(options.update.$set)
128
+ validationResult = partialSchema.validate(unflattened, { allowUnknown: false })
129
+ if (validationResult?.error) {
130
+ throw new AppError(validationResult.error.message, {
131
+ code: 'VALIDATION_ERROR',
132
+ status: 400
133
+ })
134
+ }
135
+ // Write sanitized (nested) value back — MongoDB accepts nested objects
136
+ if (validationResult.value) {
137
+ options.update.$set = validationResult.value
138
+ }
139
+ }
140
+
120
141
  if (validationResult?.error) {
121
142
  throw new AppError(validationResult.error.message, {
122
143
  code: 'VALIDATION_ERROR',
123
144
  status: 400
124
145
  })
125
146
  }
147
+ return validationResult?.value;
126
148
  }
127
149
 
128
150
  }
129
- }
130
-
131
-
132
151
 
152
+ // No schema or nothing to sanitize → pass the raw data through
153
+ return options.data;
133
154
  }
134
155
 
135
- private async #logActivity(data: {
156
+ async #logActivity(data: {
136
157
  action: string,
137
158
  collection: string,
138
159
  input?: any,
@@ -189,7 +210,7 @@ class MongoRest {
189
210
  await this.audit.addActivities([activity])
190
211
  }
191
212
 
192
- private async #executeWithAudit<T>(
213
+ async #executeWithAudit<T>(
193
214
  action: string,
194
215
  collection: string,
195
216
  input: any,
@@ -254,7 +275,17 @@ class MongoRest {
254
275
  try {
255
276
 
256
277
  const col = getCollection(collection, this.#tenant.id) as Collection
257
- const isSafe = func.isSafeAggregatePipeline(pipeline);
278
+ // Faille 12: restrict $lookup targets to this tenant's declared collections
279
+ const allowedCollections = [
280
+ ...(cfg.collections ?? [])
281
+ .filter(c => c._tenant_ === this.tenant_id)
282
+ .map(c => c.slug),
283
+ ...(cfg.fileCollections ?? [])
284
+ .filter(fc => fc._tenant_ === this.tenant_id)
285
+ .map(fc => fc.slug),
286
+ '_activities_', '_locks_', '_workflows_',
287
+ ];
288
+ const isSafe = func.isSafeAggregatePipeline(pipeline, allowedCollections);
258
289
  if (!isSafe.isSafe) {
259
290
  throw isSafe.error;
260
291
  }
@@ -362,7 +393,7 @@ class MongoRest {
362
393
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
363
394
  }
364
395
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
365
- this._validate({ collection, action: action, data: meta.data })
396
+ meta.data = this._validate({ collection, action: action, data: meta.data })
366
397
 
367
398
  await this.db.collection(collection).insertOne(func.toBson(meta.data, { col }) as any, {
368
399
  session: this.session,
@@ -400,7 +431,7 @@ class MongoRest {
400
431
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
401
432
  }
402
433
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
403
- this._validate({ collection, action: action, data: meta.data })
434
+ meta.data = this._validate({ collection, action: action, data: meta.data })
404
435
 
405
436
  await this.db.collection(collection).insertMany(func.toBson(meta.data, { col }) as any, {
406
437
  session: this.session,
@@ -429,15 +460,13 @@ class MongoRest {
429
460
  }
430
461
 
431
462
  update = await func.buildInput(update)
463
+ this._validate({ collection, action: action, update: update })
464
+ update = func.toBson(update, { col })
432
465
 
433
466
  const meta: any = { action, collection, update, id: _id }
434
467
  if (col.hooks?.beforeOperation) {
435
468
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
436
469
  }
437
- // Validate and toBson AFTER hooks
438
- update = meta.update;
439
- this._validate({ collection, action: action, update: update })
440
- update = func.toBson(update, { col })
441
470
 
442
471
  const data = await this.db.collection(collection).findOneAndUpdate(
443
472
  { _id: new ObjectId(_id) },
@@ -493,18 +522,16 @@ class MongoRest {
493
522
  }
494
523
 
495
524
 
496
- const meta: any = { action, collection, filter, update }
497
- if (col.hooks?.beforeOperation) {
498
- await col.hooks.beforeOperation({ rest: this, io, action, meta })
499
- }
500
- // Validate and toBson AFTER hooks
501
- update = meta.update;
502
- filter = meta.filter;
503
525
  this._validate({ collection, action: action, update: update.$set })
504
526
  this._validate({ collection, action: action, update: update.$setOnInsert })
505
527
  update = func.toBson(update, { col })
506
528
  filter = func.toBson(filter, { col })
507
529
 
530
+ const meta: any = { action, collection, filter, update }
531
+ if (col.hooks?.beforeOperation) {
532
+ await col.hooks.beforeOperation({ rest: this, io, action, meta })
533
+ }
534
+
508
535
  const data = await this.db.collection(collection).findOneAndUpdate(
509
536
  filter,
510
537
  {
@@ -536,15 +563,13 @@ class MongoRest {
536
563
  update.$set = await func.buildInput(update.$set, { action: action, col: col })
537
564
  }
538
565
  update = await func.buildInput(update)
566
+ this._validate({ collection, action: action, update: update })
567
+ update = func.toBson(update, { col })
539
568
 
540
569
  const meta: any = { action, collection, update, ids: _ids }
541
570
  if (col.hooks?.beforeOperation) {
542
571
  await col.hooks.beforeOperation({ rest: this, io, action, meta })
543
572
  }
544
- // Validate and toBson AFTER hooks
545
- update = meta.update;
546
- this._validate({ collection, action: action, update: update })
547
- update = func.toBson(update, { col })
548
573
 
549
574
  const result = await this.db.collection(collection).updateMany(
550
575
  { _id: { $in: _ids.map(id => new ObjectId(id)) } },
@@ -761,14 +786,14 @@ class MongoRest {
761
786
 
762
787
  const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
763
788
 
764
- return await col.actions?.[action]({
789
+ return await col.actions?.[action]?.({
765
790
  rest: this,
766
- io,
791
+ io,
767
792
  data,
768
793
  error: fn.error,
769
794
  jwt: func.jwt,
770
- token: token ?? { value: null, decoded: null },
771
- })
795
+ token: token ?? { value: null, decoded: null, provided: false, expired: false },
796
+ } as any)
772
797
  }
773
798
 
774
799
  async runService<T = any>(service: string, action: string, data?: any): Promise<T> {
@@ -785,15 +810,14 @@ class MongoRest {
785
810
 
786
811
  const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
787
812
 
788
- return await serviceInstance.actions?.[action]({
813
+ return await serviceInstance.actions?.[action]?.({
789
814
  data,
790
815
  error: fn.error,
791
816
  io,
792
817
  jwt: func.jwt,
793
818
  token: token ?? { value: null, decoded: null, provided: false, expired: false },
794
819
  rest: this,
795
- io,
796
- })
820
+ } as any)
797
821
  }
798
822
 
799
823
  async stats() {
@@ -910,8 +934,8 @@ class MongoRest {
910
934
 
911
935
  try {
912
936
  const result = await this.db.collection('_locks_').findOneAndUpdate(
913
- { _id: id, expiresAt: { $lt: now } },
914
- { $set: { _id: id, tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
937
+ { _id: id as any, expiresAt: { $lt: now } },
938
+ { $set: { tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
915
939
  { upsert: true, returnDocument: 'after' }
916
940
  );
917
941
  if (!result) {
package/index.ts CHANGED
@@ -11,7 +11,7 @@ import * as crypto from "./utils/crypto";
11
11
 
12
12
 
13
13
 
14
- // Imort bentocache use as cache
14
+ // BentoCache-based caching (memory L1 + filesystem/Redis L2)
15
15
  import { useMemoryCache, useFilesystemCache, useRedisCache } from "./utils/cache";
16
16
  const cache = {
17
17
  useMemoryCache,
package/lib/define.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import type { ServerConfig } from "../types/config";
2
- import type { Collection } from "../types/collection";
2
+ import type { Collection, CollectionAction } from "../types/collection";
3
+ import type { CollectionHook } from "../types/hook";
3
4
  import type { FileCollection } from "../types/file";
4
5
  import type { Script } from "../types/scripts";
5
6
  import type { Route } from "../types/route";
6
7
  import type { Service } from "../types/service";
7
8
  import type { WebSocketHandler } from "../types/websocket";
8
9
  import type { TenantMiddlewareConfig, GlobalMiddlewareConfig } from "../types/middleware";
10
+ import type { McpTool, McpResource } from "../types/mcp";
9
11
  import { file } from "bun";
10
12
  function Server(config: ServerConfig) {
11
- config.clusterMode = config?.clusterMode ?? true;
12
13
  config.server = {
13
14
  ...config?.server,
14
15
  }
@@ -32,6 +33,58 @@ function Collection(collection: Collection): Collection {
32
33
  }
33
34
  }
34
35
 
36
+ /**
37
+ * Defines a custom action on a collection — usable in `define.Collection({ actions: { ... } })`.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const processOrder = define.Action(async ({ rest, data, error }) => {
42
+ * const order = await rest.findOne("orders", data.orderId);
43
+ * if (!order) throw error("Order not found", { status: 404 });
44
+ * return { ok: true };
45
+ * });
46
+ *
47
+ * define.Collection({ slug: "orders", actions: { processOrder } })
48
+ * ```
49
+ */
50
+ function Action(action: CollectionAction): CollectionAction & { _isAction_: true } {
51
+ return Object.assign(action, { _isAction_: true }) as CollectionAction & { _isAction_: true };
52
+ }
53
+
54
+ /**
55
+ * Defines a collection hook — usable as `beforeOperation` / `afterOperation`.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const softDelete = define.Hook(async ({ rest, action, meta }) => {
60
+ * if (action === 'deleteOne' && meta.id) {
61
+ * await rest.updateOne("items", meta.id, { $set: { deletedAt: new Date().toISOString() } });
62
+ * }
63
+ * });
64
+ *
65
+ * define.Collection({ slug: "items", hooks: { beforeOperation: softDelete } })
66
+ * ```
67
+ */
68
+ function Hook(hook: CollectionHook): CollectionHook & { _isHook_: true } {
69
+ return Object.assign(hook, { _isHook_: true }) as CollectionHook & { _isHook_: true };
70
+ }
71
+
72
+ function McpTool(tool: McpTool): McpTool {
73
+ return {
74
+ ...tool,
75
+ enabled: tool.enabled ?? true,
76
+ _isMcpTool_: true,
77
+ }
78
+ }
79
+
80
+ function McpResource(resource: McpResource): McpResource {
81
+ return {
82
+ ...resource,
83
+ enabled: resource.enabled ?? true,
84
+ _isMcpResource_: true,
85
+ }
86
+ }
87
+
35
88
  function FileCollection(collection: FileCollection): FileCollection {
36
89
  return {
37
90
  ...collection,
@@ -103,6 +156,8 @@ export const define = {
103
156
  Server: Server,
104
157
  App: Server,
105
158
  Collection,
159
+ Action,
160
+ Hook,
106
161
  FileCollection,
107
162
  Script,
108
163
  Route,
@@ -110,5 +165,7 @@ export const define = {
110
165
  Workflow,
111
166
  TenantMiddleware,
112
167
  Middleware,
113
- WebSocket
168
+ WebSocket,
169
+ McpTool,
170
+ McpResource
114
171
  }