@anteros/core 0.0.41 → 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 (102) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +110 -32
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1327 -0
  11. package/lib/agentMemory.ts +586 -0
  12. package/lib/agents.ts +157 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/messages.ts +299 -0
  25. package/lib/providers.ts +867 -0
  26. package/lib/routes.ts +14 -1
  27. package/lib/scriptWorker.ts +4 -1
  28. package/lib/scripts.ts +6 -1
  29. package/lib/sockets.ts +3 -1
  30. package/lib/workflow.ts +209 -37
  31. package/lib/zip.ts +109 -0
  32. package/package.json +1 -1
  33. package/server/access.ts +96 -0
  34. package/server/agents.ts +451 -0
  35. package/server/api.ts +14 -72
  36. package/server/boot.ts +11 -0
  37. package/server/hono.ts +8 -3
  38. package/server/mcp.ts +17 -63
  39. package/server/routes.ts +19 -24
  40. package/tests/agent-memory.test.ts +617 -0
  41. package/tests/agent-working-memory.test.ts +402 -0
  42. package/tests/agent.test.ts +1239 -0
  43. package/tests/agents-api.test.ts +593 -0
  44. package/tests/api.test.ts +51 -0
  45. package/tests/attachment.test.ts +241 -0
  46. package/tests/audit-operations.test.ts +1 -1
  47. package/tests/audit-redact.test.ts +29 -3
  48. package/tests/audit-run-action.test.ts +1 -1
  49. package/tests/endpoints.test.ts +80 -0
  50. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  51. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  52. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  53. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  54. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  55. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  56. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  57. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  58. package/tests/fixtures/attachment/data.csv +2 -0
  59. package/tests/fixtures/fake-provider.ts +128 -0
  60. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  61. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  62. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  63. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  64. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  65. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  66. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  67. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  68. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  69. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  70. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  71. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  72. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  73. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  74. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  75. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  76. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  77. package/tests/hono.test.ts +6 -3
  78. package/tests/locks.test.ts +120 -0
  79. package/tests/mcp.test.ts +11 -11
  80. package/tests/messages.test.ts +258 -0
  81. package/tests/replication-control.test.ts +2 -2
  82. package/tests/replication-meta.test.ts +243 -0
  83. package/tests/upload.test.ts +44 -12
  84. package/tests/vars-api.test.ts +1 -1
  85. package/tests/vars-replication.test.ts +3 -2
  86. package/tests/workflow-engine.test.ts +340 -0
  87. package/tests/workflow-retention.test.ts +103 -0
  88. package/tests/workflow.test.ts +186 -0
  89. package/types/agent.d.ts +837 -0
  90. package/types/config.d.ts +16 -0
  91. package/types/file.d.ts +7 -7
  92. package/types/hook.d.ts +6 -0
  93. package/types/lifecycle.d.ts +6 -0
  94. package/types/mcp.d.ts +11 -1
  95. package/types/replication.d.ts +29 -0
  96. package/types/route.d.ts +7 -1
  97. package/types/scripts.d.ts +6 -0
  98. package/types/service.d.ts +7 -1
  99. package/types/tenant.d.ts +13 -0
  100. package/types/vars.d.ts +2 -2
  101. package/types/websocket.d.ts +6 -0
  102. package/types/workflow.d.ts +50 -3
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
- // Create the indexes on _workflows_ for the defined contexts
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.41",
4
+ "version": "0.0.42",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
@@ -0,0 +1,96 @@
1
+ import { AppError, fn } from "../lib/error";
2
+ import * as func from "../utils/func";
3
+ import type { useRest } from "../database/rest";
4
+
5
+ /**
6
+ * Shared HTTP plumbing between the collection/vars/service API (`server/api.ts`)
7
+ * and the agents API (`server/agents.ts`).
8
+ *
9
+ * Extracted rather than duplicated: the access rule semantics (wildcard, boolean
10
+ * vs function, token requirement) must stay identical everywhere — a divergence
11
+ * here is a hole.
12
+ */
13
+
14
+ function getAccessToken(c: any) {
15
+ const t = c.get('token');
16
+ return {
17
+ value: (t?.value ?? null) as string | null,
18
+ decoded: (t?.decoded ?? null) as Record<string, unknown> | null,
19
+ provided: (t?.provided ?? false) as boolean,
20
+ expired: (t?.expired ?? false) as boolean,
21
+ };
22
+ }
23
+
24
+ function errorResponse(c: any, err: any) {
25
+ const isAppError = err instanceof AppError;
26
+ const status = isAppError ? Number(err.status) : 500;
27
+ return c.json({
28
+ message: isAppError ? err.message : 'Internal server error',
29
+ code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
30
+ meta: isAppError ? err.meta : undefined,
31
+ }, status);
32
+ }
33
+
34
+ /**
35
+ * Evaluate an `api.access` rule — **no rule = denied** (secure by default).
36
+ *
37
+ * - `true` → allowed without a token (public)
38
+ * - `false` → denied
39
+ * - function → **requires a valid, non-expired token**, then the rule decides
40
+ *
41
+ * `extra` is merged into the rule context (agents pass the agent, the action and
42
+ * the input so a rule can decide on the payload it is about to run).
43
+ */
44
+ async function evaluateAccess(
45
+ access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
46
+ operation: string,
47
+ rest: InstanceType<typeof useRest>,
48
+ label: string,
49
+ c: any,
50
+ extra: Record<string, any> = {},
51
+ ): Promise<void> {
52
+ if (!access) {
53
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
54
+ }
55
+
56
+ const hasWildcard = access['*'] !== undefined;
57
+ const hasSpecific = access[operation] !== undefined;
58
+
59
+ if (!hasWildcard && !hasSpecific) {
60
+ throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
61
+ }
62
+
63
+ const rule = hasSpecific ? access[operation] : access['*'];
64
+ if (rule === undefined) return;
65
+
66
+ // Boolean `true` → allow without requiring a token
67
+ if (typeof rule === 'boolean') {
68
+ if (!rule) {
69
+ throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
70
+ }
71
+ return;
72
+ }
73
+
74
+ // Function → requires a valid token
75
+ const accessToken = getAccessToken(c);
76
+
77
+ if (accessToken.expired) {
78
+ throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
79
+ }
80
+ if (!accessToken.value) {
81
+ throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
82
+ }
83
+
84
+ const allowed = await (rule as Function)({
85
+ rest,
86
+ error: fn.error,
87
+ jwt: func.jwt,
88
+ token: accessToken,
89
+ ...extra,
90
+ });
91
+ if (!allowed) {
92
+ throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
93
+ }
94
+ }
95
+
96
+ export { getAccessToken, errorResponse, evaluateAccess };