@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.
- package/database/audit.ts +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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 }); //
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
282
|
-
|
|
431
|
+
if (caps.scripts) {
|
|
432
|
+
setTimeout(() => {
|
|
433
|
+
runScripts().catch(); // run scripts
|
|
283
434
|
|
|
284
|
-
|
|
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 });
|