@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/lib/redact.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { cfg } from "../server/config";
2
+
3
+ /**
4
+ * Keys whose value is replaced by `[redacted]` before an audit entry is stored
5
+ * (in `_audit_` **and** in the local file backup), at any depth.
6
+ *
7
+ * Override with `server.audit.redact: ['…']`, disable with `server.audit.redact: false`.
8
+ */
9
+ const DEFAULT_REDACT_KEYS = [
10
+ 'password',
11
+ 'passwd',
12
+ 'pwd',
13
+ 'token',
14
+ 'auth_token',
15
+ 'access_token',
16
+ 'refresh_token',
17
+ 'id_token',
18
+ 'authorization',
19
+ 'apikey',
20
+ 'api_key',
21
+ 'key',
22
+ 'secret',
23
+ 'cookie',
24
+ 'session_id',
25
+ 'otp',
26
+ 'pin',
27
+ ]
28
+
29
+ const REDACTED = '[redacted]'
30
+
31
+ /** Depth guard — a pathological payload must never cost more than a bounded walk */
32
+ const MAX_DEPTH = 8
33
+
34
+ // Cache initialised for the default state (`redact` unset) — a sentinel like
35
+ // `undefined` would be indistinguishable from the real, unset configuration.
36
+ let cachedConfig: unknown = undefined
37
+ let cachedKeys: Set<string> | null = new Set(DEFAULT_REDACT_KEYS.map((key) => key.toLowerCase()))
38
+
39
+ /** Lower-cased redaction keys, or `null` when redaction is disabled */
40
+ function redactKeys(): Set<string> | null {
41
+ const configured = cfg.server?.audit?.redact
42
+ if (configured !== cachedConfig) {
43
+ cachedConfig = configured
44
+ cachedKeys = configured === false
45
+ ? null
46
+ : new Set((configured ?? DEFAULT_REDACT_KEYS).map((key) => key.toLowerCase()))
47
+ }
48
+ return cachedKeys
49
+ }
50
+
51
+ /**
52
+ * Is this key sensitive? Exact match, case-insensitive, `-`/`.` normalised to `_`
53
+ * (`api-key` → `api_key`), plus the usual header prefix (`x-api-key` → `api_key`).
54
+ *
55
+ * Deliberately **not** a substring match: `key` must not match `monkey`.
56
+ */
57
+ function isSensitiveKey(name: string, keys: Set<string>): boolean {
58
+ const normalized = name.toLowerCase().replace(/[-.]/g, '_')
59
+ if (keys.has(normalized)) return true
60
+ if (normalized.startsWith('x_')) return keys.has(normalized.slice(2))
61
+ return false
62
+ }
63
+
64
+ /**
65
+ * Replace the value of every sensitive key (see `isSensitiveKey`), at any depth.
66
+ * Arrays are traversed; **non-plain objects** (`Date`, `ObjectId`, `Buffer`,
67
+ * `Map`…) are left untouched so stored types are never altered.
68
+ *
69
+ * Returns the **same reference** when nothing matched — the common case (no
70
+ * sensitive key in the payload) costs the walk but no allocation.
71
+ */
72
+ function redact(value: any, keys: Set<string>, depth = 0): any {
73
+ if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value
74
+
75
+ if (Array.isArray(value)) {
76
+ let changed = false
77
+ const out = value.map((item) => {
78
+ const next = redact(item, keys, depth + 1)
79
+ if (next !== item) changed = true
80
+ return next
81
+ })
82
+ return changed ? out : value
83
+ }
84
+
85
+ const proto = Object.getPrototypeOf(value)
86
+ if (proto !== Object.prototype && proto !== null) return value
87
+
88
+ let changed = false
89
+ const out: Record<string, any> = {}
90
+ for (const [name, item] of Object.entries(value)) {
91
+ if (isSensitiveKey(name, keys)) {
92
+ out[name] = REDACTED
93
+ changed = true
94
+ continue
95
+ }
96
+ const next = redact(item, keys, depth + 1)
97
+ if (next !== item) changed = true
98
+ out[name] = next
99
+ }
100
+ return changed ? out : value
101
+ }
102
+
103
+ /** Redact an audit payload — `input`, `result`, `meta` or `request` */
104
+ function redactPayload(value: any): any {
105
+ const keys = redactKeys()
106
+ if (!keys || value === undefined || value === null) return value
107
+ return redact(value, keys)
108
+ }
109
+
110
+ export {
111
+ DEFAULT_REDACT_KEYS,
112
+ REDACTED,
113
+ redactKeys,
114
+ isSensitiveKey,
115
+ redactPayload,
116
+ redact,
117
+ }
package/lib/routes.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Glob } from "bun"
2
2
  import { cfg } from "../server/config"
3
+ import { isReservedFamily } from "./endpoints"
3
4
  import path from "path"
4
5
  import fs from "fs/promises"
5
6
 
@@ -25,6 +26,18 @@ async function loadRoutes() {
25
26
  _tenant_: tenant.id,
26
27
  _prefix_: tenant.routes?.prefix,
27
28
  })
29
+
30
+ // A route mounted under a family segment the framework owns
31
+ // (`/api/v1/vars/x`) is registered first and would shadow the
32
+ // framework API silently — say it out loud.
33
+ const path = String(routeModule.default.path ?? '');
34
+ const family = path.replace(/^\/+/, '').split('/')[0];
35
+ if (family && isReservedFamily(family)) {
36
+ console.warn(
37
+ `Route '${tenant.routes?.prefix}${path}' of tenant '${tenant.id}' shadows the `
38
+ + `framework '/api/…/${family}/…' API: rename it or change the route prefix.`,
39
+ );
40
+ }
28
41
  }
29
42
  }
30
43
  }
@@ -41,4 +54,4 @@ async function loadRoutes() {
41
54
 
42
55
  export {
43
56
  loadRoutes
44
- }
57
+ }
@@ -3,6 +3,8 @@ import { useRest } from "../database/rest";
3
3
  import { syncTenants } from "../database/tenant";
4
4
  import { syncCollections } from "../database/collection";
5
5
  import { syncFileCollections } from "../database/file";
6
+ import { syncAgents, createAgents } from "./agents";
7
+ import { createApi } from "./api";
6
8
  import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage";
7
9
 
8
10
  // ─── Heavy-script worker entrypoint ─────────────────────────────────────────
@@ -42,6 +44,7 @@ async function main() {
42
44
  await syncTenants();
43
45
  await syncCollections();
44
46
  await syncFileCollections();
47
+ await syncAgents(); // a heavy script gets the same `agents` registry as any other context
45
48
  } catch (err: any) {
46
49
  console.error('[script-worker] failed to bootstrap context:', err?.message);
47
50
  process.exit(1);
@@ -62,7 +65,7 @@ async function main() {
62
65
  await asyncContextStorage.run(new Map(), async () => {
63
66
  requestCtxStorage.set('trace', { id: crypto.randomUUID() });
64
67
  requestCtxStorage.set('internal', true);
65
- await script.exec({ rest, progress, tenant });
68
+ await script.exec({ rest, progress, tenant, agents: createAgents(tenant.id, rest), api: createApi(rest) });
66
69
  });
67
70
  process.send?.({ type: 'done' });
68
71
  process.exit(0);
package/lib/scripts.ts CHANGED
@@ -5,6 +5,8 @@ import fs from "fs/promises"
5
5
  import type { Script } from "../types/scripts"
6
6
  import type { Tenant } from "../types/tenant"
7
7
  import { useRest } from "../database/rest"
8
+ import { createAgents } from "./agents"
9
+ import { createApi } from "./api"
8
10
  import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage"
9
11
 
10
12
 
@@ -95,9 +97,12 @@ async function runScripts() {
95
97
  await asyncContextStorage.run(new Map(), async () => {
96
98
  requestCtxStorage.set('trace', { id: crypto.randomUUID() })
97
99
  requestCtxStorage.set('internal', true)
100
+ const rest = new useRest({ tenant_id: tenant.id })
98
101
  await script.exec({
99
- rest: new useRest({ tenant_id: tenant.id }),
102
+ rest,
100
103
  tenant,
104
+ agents: createAgents(tenant.id, rest),
105
+ api: createApi(rest),
101
106
  })
102
107
  })
103
108
  }
package/lib/sockets.ts CHANGED
@@ -3,6 +3,8 @@ import { cfg } from "../server/config"
3
3
  import path from "path"
4
4
  import fs from "fs/promises"
5
5
  import { useRest } from "../database/rest"
6
+ import { createAgents } from "../lib/agents"
7
+ import { createApi } from "../lib/api"
6
8
  import { io } from "../server/io"
7
9
  import type { WebSocketHandler } from "../types/websocket"
8
10
 
@@ -26,7 +28,7 @@ async function loadSockets() {
26
28
  const rest = new useRest({ tenant_id: tenant.id })
27
29
 
28
30
  io.on('connection', (socket) => {
29
- handler.exec({ io, rest, socket })
31
+ handler.exec({ io, rest, socket, agents: createAgents(tenant.id, rest), api: createApi(rest) })
30
32
  })
31
33
 
32
34
  console.log(`[socket] ${tenant.id}/${path.basename(file, '.ws.ts')} loaded`)
package/lib/workflow.ts CHANGED
@@ -1,60 +1,232 @@
1
1
  import { Glob } from "bun"
2
- import { cfg } from "../server/config"
3
2
  import path from "path"
4
3
  import fs from "fs/promises"
5
- import type { WorkflowDefinition } from "../types/workflow"
4
+ import { cfg } from "../server/config"
5
+ import { logger } from "../utils/logger"
6
+ import { AppError } from "./error"
7
+ import { syncTtlRetention } from "../database/ttl"
8
+ import type { WorkflowDefinition, WorkflowIndex } from "../types/workflow"
9
+ import type { Tenant } from "../types/tenant"
6
10
  import { getTenant } from "../database/tenant"
7
11
 
12
+ /** Every workflow of every tenant, keyed by `{tenant}:{id}` */
8
13
  const workflows: Map<string, WorkflowDefinition> = new Map();
9
14
 
15
+ /**
16
+ * Indexes the engine itself filters on (see `database/workflow.ts`):
17
+ * - `listRuns()` → `find({ workflowId }).sort({ createdAt: -1 })`
18
+ * - `resumeAll()` → `find({ status: { $in: […] } })`
19
+ *
20
+ * Field names match MongoDB's default index naming, so an existing index with
21
+ * the same key is recognised (and never re-created or duplicated).
22
+ */
23
+ const ENGINE_INDEXES: Record<string, 1 | -1>[] = [
24
+ { workflowId: 1, createdAt: -1 },
25
+ { status: 1 },
26
+ ]
27
+
28
+ /** TTL index used for run retention — only prunes **finished** runs (`completedAt`) */
29
+ const RUNS_TTL_INDEX = '_workflow_runs_ttl_'
30
+
31
+ /**
32
+ * Run retention — **the tenant always wins**, `server.workflows.retention` is
33
+ * the fallback:
34
+ * - `tenant.workflows.retention` set (even `false`) → used as-is
35
+ * - otherwise → `server.workflows.retention`
36
+ * - neither → `undefined` (kept forever)
37
+ */
38
+ function resolveWorkflowsRetention(tenant: Tenant): string | false | undefined {
39
+ if (tenant.workflows?.retention !== undefined) return tenant.workflows.retention
40
+ return cfg.server?.workflows?.retention
41
+ }
42
+
43
+ /** MongoDB's default index name for a key spec — `{ a: 1, b: -1 }` → `a_1_b_-1` */
44
+ function indexName(key: Record<string, 1 | -1>): string {
45
+ return Object.entries(key).map(([field, direction]) => `${field}_${direction}`).join('_')
46
+ }
47
+
10
48
  async function syncWorkflows() {
11
- try {
12
- workflows.clear();
13
- for (let tenant of cfg.tenants ?? []) {
14
- const TENANT_PATH = path.join(process.cwd(), tenant.dir)
15
- const WORKFLOWS_PATH = path.join(TENANT_PATH, 'workflows')
16
- let exist = await fs.exists(WORKFLOWS_PATH)
17
- if (!exist) continue;
18
- const isDirectory = await (await fs.stat(WORKFLOWS_PATH)).isDirectory()
19
- if (!isDirectory) continue;
49
+ workflows.clear()
50
+
51
+ // ── Discovery — `{tenant.dir}/workflows/**/*.workflow.ts` ──────────────
52
+ // A broken file (or a broken tenant) must never hide the others.
53
+ for (const tenant of cfg.tenants ?? []) {
54
+ const WORKFLOWS_PATH = path.join(process.cwd(), tenant.dir, 'workflows')
55
+ try {
56
+ if (!(await fs.exists(WORKFLOWS_PATH))) continue
57
+ if (!(await fs.stat(WORKFLOWS_PATH)).isDirectory()) continue
20
58
 
21
59
  const glob = new Glob(path.join(WORKFLOWS_PATH, '**/*.workflow.ts'))
22
- for await (let file of glob.scan('.')) {
23
- let module = await import(file)
24
- if (module?.default?._isWorkflow_) {
25
- const wf: WorkflowDefinition = { ...module.default, _tenant_: tenant.id }
26
- workflows.set(`${tenant.id}:${wf.id}`, wf)
60
+ for await (const file of glob.scan('.')) {
61
+ try {
62
+ const module = await import(file)
63
+ const definition = module?.default as WorkflowDefinition | undefined
64
+ if (!definition?._isWorkflow_) continue
65
+ if (definition.enabled === false) continue
66
+ if (!definition.id) {
67
+ console.warn(`⚠ Workflow ignored (no "id"): ${file}`)
68
+ continue
69
+ }
70
+
71
+ const key = `${tenant.id}:${definition.id}`
72
+ if (workflows.has(key)) {
73
+ console.warn(
74
+ `⚠ Duplicate workflow id '${definition.id}' for tenant '${tenant.id}' — ${file} overrides the previous definition`,
75
+ )
76
+ }
77
+ workflows.set(key, { ...definition, _tenant_: tenant.id })
78
+ } catch (err: any) {
79
+ console.error(`Failed to load workflow '${file}':`, err?.message)
27
80
  }
28
81
  }
82
+ } catch (err: any) {
83
+ console.error(`Failed to load workflows for tenant '${tenant.id}':`, err?.message)
29
84
  }
85
+ }
30
86
 
31
- // Créer les indexes sur _workflows_ pour les contextes définis
32
- for (const [key, wf] of workflows) {
33
- const tenantId = key.split(':')[0]
34
- if (!wf.context || !tenantId) continue
35
- const tenant = getTenant(tenantId)
36
- const db = tenant?.database?.db
37
- if (!db) continue
38
-
39
- for (const [field, config] of Object.entries(wf.context)) {
40
- const direction = config.index === true || config.index === 1 ? 1 : config.index === -1 ? -1 : 0
41
- if (direction === 0) continue
42
-
43
- try {
44
- const indexKey = `context.${field}`
45
- await db.collection('_workflows_').createIndex({ [indexKey]: direction })
46
- } catch (err: any) {
47
- console.error(`Failed to create index on _workflows_.${field}:`, err?.message)
87
+ logger.file('workflows: loaded', {
88
+ total: workflows.size,
89
+ tenants: [...new Set([...workflows.values()].map((wf) => wf._tenant_))],
90
+ })
91
+
92
+ // Indexes are a background concern: boot never waits for an index build.
93
+ ensureWorkflowIndexes().catch((err) => console.error('Failed to create workflow indexes:', err?.message))
94
+ }
95
+
96
+ /**
97
+ * Create the indexes `_workflows_` needs — the engine's query fields, the
98
+ * `context` fields declared by the workflows, and any explicit `indexes`.
99
+ * Only the missing ones are built (`background: true`), and a failure is
100
+ * logged, never fatal.
101
+ */
102
+ async function ensureWorkflowIndexes(): Promise<void> {
103
+ for (const tenant of cfg.tenants ?? []) {
104
+ const db = getTenant(tenant.id)?.database?.db
105
+ if (!db) continue
106
+
107
+ // Desired indexes: engine queries first, then what the workflows declare
108
+ const desired = new Map<string, { key: Record<string, 1 | -1>; options: any }>()
109
+ for (const key of ENGINE_INDEXES) {
110
+ desired.set(indexName(key), { key, options: { background: true } })
111
+ }
112
+
113
+ for (const wf of workflows.values()) {
114
+ if (wf._tenant_ !== tenant.id) continue
115
+
116
+ // `context: { field: { index: true | 1 | -1 } }`
117
+ for (const [field, config] of Object.entries(wf.context ?? {})) {
118
+ const direction = config.index === true || config.index === 1 ? 1 : config.index === -1 ? -1 : 0
119
+ if (!direction) continue
120
+ const key = { [`context.${field}`]: direction } as Record<string, 1 | -1>
121
+ desired.set(indexName(key), { key, options: { background: true } })
122
+ }
123
+
124
+ // `indexes: [{ key, name?, unique?, sparse? }]` — compound and friends
125
+ for (const index of wf.indexes ?? []) {
126
+ if (!index?.key || !Object.keys(index.key).length) continue
127
+ // Never send `null`/`undefined` on the wire: Mongo rejects `unique: null`
128
+ desired.set(index.name ?? indexName(index.key), {
129
+ key: index.key,
130
+ options: {
131
+ background: true,
132
+ ...(index.name ? { name: index.name } : {}),
133
+ ...(index.unique !== undefined ? { unique: index.unique } : {}),
134
+ ...(index.sparse !== undefined ? { sparse: index.sparse } : {}),
135
+ },
136
+ })
137
+ }
138
+ }
139
+
140
+ try {
141
+ // `listIndexes()` requires the collection to exist — create it first,
142
+ // like the file-collection loader does (`_workflows_` is only written
143
+ // when a workflow actually runs).
144
+ const exists = await db.listCollections({ name: '_workflows_' }).toArray()
145
+ if (!exists.length) await db.createCollection('_workflows_')
146
+
147
+ const collection = db.collection('_workflows_')
148
+ const existing = new Set(
149
+ (await collection.listIndexes().toArray()).map((index: any) => index.name),
150
+ )
151
+
152
+ for (const [name, { key, options }] of desired) {
153
+ if (existing.has(name)) continue
154
+ await collection.createIndex(key as any, options)
155
+ logger.file('workflows: index created', { tenant: tenant.id, index: name })
156
+ }
157
+
158
+ // Retention of finished runs — opt-in (`completedAt` is only set when a
159
+ // run finishes, so running/paused/failed runs are never pruned)
160
+ const retention = resolveWorkflowsRetention(tenant)
161
+ if (retention !== undefined) {
162
+ await syncTtlRetention(db, {
163
+ collection: '_workflows_',
164
+ index: RUNS_TTL_INDEX,
165
+ field: 'completedAt',
166
+ retention,
167
+ label: 'Workflows',
168
+ })
169
+ }
170
+ } catch (err: any) {
171
+ console.error(`Failed to create workflow indexes for tenant '${tenant.id}':`, err?.message)
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Validate — and coerce — the `context` of a run against the workflow's own
178
+ * declaration: this is what the `type` of a context field is for. Undeclared
179
+ * keys are kept as-is (the declaration is a contract on what you declare, not a
180
+ * closed schema). Dates sent as ISO strings are stored as real `Date`s, so the
181
+ * indexes on `context.*` stay typed.
182
+ */
183
+ function validateWorkflowContext(wf: WorkflowDefinition, context: any): any {
184
+ if (!wf.context || context === undefined || context === null || typeof context !== 'object') {
185
+ return context
186
+ }
187
+
188
+ const out: Record<string, any> = { ...context }
189
+ const errors: string[] = []
190
+
191
+ for (const [field, spec] of Object.entries(wf.context)) {
192
+ const value = out[field]
193
+ if (value === undefined || value === null) continue
194
+
195
+ switch (spec.type) {
196
+ case 'string':
197
+ if (typeof value !== 'string') errors.push(`'${field}' must be a string (received ${typeof value})`)
198
+ break
199
+ case 'number':
200
+ if (typeof value !== 'number' || Number.isNaN(value)) errors.push(`'${field}' must be a number (received ${typeof value})`)
201
+ break
202
+ case 'date': {
203
+ const date = value instanceof Date ? value : new Date(String(value))
204
+ if (Number.isNaN(date.getTime())) errors.push(`'${field}' must be a date (Date or ISO string)`)
205
+ else out[field] = date
206
+ break
48
207
  }
49
- }
50
208
  }
51
- } catch (err: any) {
52
- console.error('Failed to sync workflows:', err?.message)
53
209
  }
210
+
211
+ if (errors.length) {
212
+ throw new AppError(`Invalid workflow context: ${errors.join('; ')}`, {
213
+ code: 'WORKFLOW_CONTEXT_INVALID',
214
+ status: 400,
215
+ })
216
+ }
217
+ return out
54
218
  }
55
219
 
56
220
  function getWorkflow(id: string, tenantId: string): WorkflowDefinition | undefined {
57
221
  return workflows.get(`${tenantId}:${id}`)
58
222
  }
59
223
 
60
- export { syncWorkflows, getWorkflow }
224
+ /** What was loaded — used by the boot banner and logs */
225
+ function workflowStats(): { total: number; tenants: string[] } {
226
+ return {
227
+ total: workflows.size,
228
+ tenants: [...new Set([...workflows.values()].map((wf) => wf._tenant_!))],
229
+ }
230
+ }
231
+
232
+ export { syncWorkflows, ensureWorkflowIndexes, validateWorkflowContext, resolveWorkflowsRetention, getWorkflow, workflowStats, RUNS_TTL_INDEX as WORKFLOW_RUNS_TTL_INDEX }
package/lib/zip.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { inflateRawSync } from "node:zlib";
2
+ import { AppError } from "./error";
3
+
4
+ /**
5
+ * A minimal, **read-only** ZIP reader — enough to open a `.docx` or a `.xlsx`
6
+ * (both are ZIP containers) without adding a dependency.
7
+ *
8
+ * `Bun.Archive` only handles tar, and a document is not worth a package: the
9
+ * central directory of a ZIP is a simple, stable structure — find it, list the
10
+ * entries, inflate the one you need. Entries are decompressed **lazily**, so a
11
+ * workbook full of images never pays for them.
12
+ *
13
+ * Deliberately not supported (they never appear in an Office file): ZIP64
14
+ * archives, encrypted entries and multi-disk sets.
15
+ */
16
+
17
+ const SIGNATURE = {
18
+ end: 0x06054b50,
19
+ central: 0x02014b50,
20
+ local: 0x04034b50,
21
+ } as const;
22
+
23
+ /** Zip entries are never larger than this in a document we read — sanity guard. */
24
+ const MAX_EOCD_SCAN = 65_535 + 22; // the folder comment cap + the record itself
25
+
26
+ type Entry = {
27
+ method: number;
28
+ compressedSize: number;
29
+ localOffset: number;
30
+ };
31
+
32
+ export type ZipArchive = {
33
+ /** Every entry name, in central-directory order. */
34
+ names(): string[];
35
+ /** The decompressed bytes of an entry, or `null` when it does not exist. */
36
+ read(name: string): Uint8Array | null;
37
+ };
38
+
39
+ function bad(reason: string): AppError {
40
+ return new AppError(`Unreadable archive: ${reason}`, { status: 400, code: "AGENT_FILE_INVALID" });
41
+ }
42
+
43
+ /** Open a ZIP buffer — throws `AGENT_FILE_INVALID` when it is not a readable ZIP. */
44
+ function openZip(buffer: Uint8Array): ZipArchive {
45
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
46
+
47
+ // ── End Of Central Directory — the only reliable place to start ───────
48
+ let end = -1;
49
+ const lowest = Math.max(0, buffer.length - MAX_EOCD_SCAN);
50
+ for (let offset = buffer.length - 22; offset >= lowest; offset--) {
51
+ if (view.getUint32(offset, true) === SIGNATURE.end) {
52
+ end = offset;
53
+ break;
54
+ }
55
+ }
56
+ if (end < 0) throw bad("no ZIP central directory (is it a real .docx/.xlsx?)");
57
+
58
+ const entries = new Map<string, Entry>();
59
+ const count = view.getUint16(end + 10, true);
60
+ let offset = view.getUint32(end + 16, true);
61
+
62
+ // ── Central directory ────────────────────────────────────────────────
63
+ for (let index = 0; index < count; index++) {
64
+ if (offset + 46 > buffer.length || view.getUint32(offset, true) !== SIGNATURE.central) {
65
+ throw bad("truncated central directory");
66
+ }
67
+
68
+ const method = view.getUint16(offset + 10, true);
69
+ const compressedSize = view.getUint32(offset + 20, true);
70
+ const nameLength = view.getUint16(offset + 28, true);
71
+ const extraLength = view.getUint16(offset + 30, true);
72
+ const commentLength = view.getUint16(offset + 32, true);
73
+ const localOffset = view.getUint32(offset + 42, true);
74
+
75
+ if (compressedSize === 0xffffffff || localOffset === 0xffffffff) {
76
+ throw bad("ZIP64 archives are not supported");
77
+ }
78
+
79
+ const name = new TextDecoder().decode(buffer.subarray(offset + 46, offset + 46 + nameLength));
80
+ entries.set(name, { method, compressedSize, localOffset });
81
+ offset += 46 + nameLength + extraLength + commentLength;
82
+ }
83
+
84
+ return {
85
+ names: () => [...entries.keys()],
86
+
87
+ read(name: string): Uint8Array | null {
88
+ const entry = entries.get(name);
89
+ if (!entry) return null;
90
+
91
+ // The local header repeats the name/extra — the payload starts after them.
92
+ const local = entry.localOffset;
93
+ if (local + 30 > buffer.length || view.getUint32(local, true) !== SIGNATURE.local) {
94
+ throw bad(`corrupted entry '${name}'`);
95
+ }
96
+
97
+ const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true);
98
+ const data = buffer.subarray(start, start + entry.compressedSize);
99
+ if (start + entry.compressedSize > buffer.length) throw bad(`truncated entry '${name}'`);
100
+
101
+ // 0 = stored, 8 = deflate — the only two methods Office writes
102
+ if (entry.method === 0) return data;
103
+ if (entry.method === 8) return new Uint8Array(inflateRawSync(data));
104
+ throw bad(`unsupported compression method ${entry.method} for '${name}'`);
105
+ },
106
+ };
107
+ }
108
+
109
+ export { openZip };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.40",
4
+ "version": "0.0.42",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,