@anteros/core 0.0.41 → 0.0.44
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 +9 -59
- package/database/collection.ts +3 -1
- package/database/deleteLog.ts +67 -3
- package/database/file.ts +4 -3
- package/database/mongodbadapter.ts +103 -42
- package/database/replication.ts +724 -58
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1567 -0
- package/lib/agentMemory.ts +689 -0
- package/lib/agents.ts +180 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/auditResult.ts +13 -0
- package/lib/define.ts +43 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +2 -1
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +7 -1
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/mcpClient.ts +128 -0
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +970 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/skills.ts +481 -0
- 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 +472 -0
- package/server/api.ts +80 -65
- package/server/boot.ts +11 -0
- package/server/hono.ts +8 -3
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +404 -0
- package/tests/agent.test.ts +1241 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-operations.test.ts +1 -1
- package/tests/audit-redact.test.ts +29 -3
- package/tests/audit-run-action.test.ts +1 -1
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +2 -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/repl-only/app.ts +3 -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/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
- 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/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +4 -2
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +246 -0
- package/tests/replication.test.ts +2 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +5 -2
- 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 +1067 -0
- package/types/config.d.ts +16 -0
- package/types/file.d.ts +7 -7
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +6 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +70 -4
- 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 +13 -0
- package/types/vars.d.ts +2 -2
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
package/server/access.ts
ADDED
|
@@ -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 };
|