@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { cfg } from "../server/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What an audit entry keeps of an operation's **result** (default: `summary`).
|
|
5
|
+
*
|
|
6
|
+
* - `summary` → the **generated identifiers** (`_id` / `insertedIds`), plus the
|
|
7
|
+
* operational counters (`matchedCount`, `deletedCount`,
|
|
8
|
+
* `nIndexesWas`, …). Never a document.
|
|
9
|
+
* - `none` → the identifiers only.
|
|
10
|
+
* - `full` → the raw result as returned by the operation (documents included).
|
|
11
|
+
*/
|
|
12
|
+
export type AuditResults = 'none' | 'summary' | 'full'
|
|
13
|
+
|
|
14
|
+
/** Cap on the identifiers kept for a batch insert — the count stays exact */
|
|
15
|
+
const IDS_MAX = 100
|
|
16
|
+
|
|
17
|
+
const COUNTER_KEYS = [
|
|
18
|
+
'acknowledged',
|
|
19
|
+
'matchedCount',
|
|
20
|
+
'modifiedCount',
|
|
21
|
+
'upsertedCount',
|
|
22
|
+
'upsertedId',
|
|
23
|
+
'insertedCount',
|
|
24
|
+
'deletedCount',
|
|
25
|
+
'insertedIds',
|
|
26
|
+
'upsertedIds',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
// Default state (`results` unset = `summary`) — a sentinel like `undefined`
|
|
30
|
+
// would be indistinguishable from the real, unset configuration.
|
|
31
|
+
let cachedConfig: unknown = undefined
|
|
32
|
+
let cachedMode: AuditResults = 'summary'
|
|
33
|
+
|
|
34
|
+
function auditResultsMode(): AuditResults {
|
|
35
|
+
const configured = cfg.server?.audit?.results
|
|
36
|
+
if (configured !== cachedConfig) {
|
|
37
|
+
cachedConfig = configured
|
|
38
|
+
cachedMode = configured === 'full' || configured === 'none' ? configured : 'summary'
|
|
39
|
+
}
|
|
40
|
+
return cachedMode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Generated identifiers of an operation whose result is a document — never the document */
|
|
44
|
+
function identifiers(action: string, result: any): any {
|
|
45
|
+
// `insertOne`, `updateOne`, `findOneAndUpdate` and a file `upload` all return
|
|
46
|
+
// the document (created / updated / resulting) — only its `_id` may enter the trail.
|
|
47
|
+
if (action === 'insertOne' || action === 'updateOne' || action === 'findOneAndUpdate' || action === 'upload') {
|
|
48
|
+
return result?._id ? { _id: result._id } : null
|
|
49
|
+
}
|
|
50
|
+
if (action === 'insertMany') {
|
|
51
|
+
const ids = Array.isArray(result)
|
|
52
|
+
? result.map((doc) => doc?._id).filter((id) => typeof id === 'string')
|
|
53
|
+
: []
|
|
54
|
+
return {
|
|
55
|
+
count: ids.length,
|
|
56
|
+
...(ids.length ? { insertedIds: ids.slice(0, IDS_MAX) } : {}),
|
|
57
|
+
...(ids.length > IDS_MAX ? { truncated: true } : {}),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pick the counter keys actually present in a driver result */
|
|
64
|
+
function counters(result: any): any {
|
|
65
|
+
const out: any = {}
|
|
66
|
+
for (const key of COUNTER_KEYS) {
|
|
67
|
+
if (result?.[key] !== undefined) out[key] = result[key]
|
|
68
|
+
}
|
|
69
|
+
return Object.keys(out).length ? out : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reduce an operation's result to what the audit trail may store.
|
|
74
|
+
* `operation.input` already holds the parameters (and, for writes, the change
|
|
75
|
+
* itself) — the result is only useful as *proof the operation had an effect*.
|
|
76
|
+
*/
|
|
77
|
+
function summarizeAuditResult(action: string, result: any): any {
|
|
78
|
+
const mode = auditResultsMode()
|
|
79
|
+
if (mode === 'full') return result ?? null
|
|
80
|
+
if (result === null || result === undefined) return null
|
|
81
|
+
|
|
82
|
+
if (mode === 'none') return identifiers(action, result)
|
|
83
|
+
|
|
84
|
+
switch (action) {
|
|
85
|
+
case 'insertOne':
|
|
86
|
+
case 'insertMany':
|
|
87
|
+
case 'updateOne':
|
|
88
|
+
case 'findOneAndUpdate':
|
|
89
|
+
case 'upload':
|
|
90
|
+
// Document-returning operations: identifiers only
|
|
91
|
+
return identifiers(action, result)
|
|
92
|
+
case 'updateMany':
|
|
93
|
+
case 'bulkWrite':
|
|
94
|
+
case 'bulkUpdate':
|
|
95
|
+
return counters(result)
|
|
96
|
+
case 'deleteOne':
|
|
97
|
+
case 'deleteFile':
|
|
98
|
+
// `deleteOne` returns the deleted document — keep only "it happened"
|
|
99
|
+
return { deleted: true }
|
|
100
|
+
case 'deleteMany':
|
|
101
|
+
return counters(result)
|
|
102
|
+
case 'countDocuments':
|
|
103
|
+
return typeof result === 'number' ? result : null
|
|
104
|
+
case 'dropCollection':
|
|
105
|
+
return typeof result === 'boolean' ? result : null
|
|
106
|
+
case 'dropIndex':
|
|
107
|
+
return typeof result === 'string' ? result : null
|
|
108
|
+
case 'dropIndexes':
|
|
109
|
+
return result?.nIndexesWas !== undefined ? { nIndexesWas: result.nIndexesWas } : null
|
|
110
|
+
case 'agent.generate':
|
|
111
|
+
case 'agent.stream':
|
|
112
|
+
case 'agent.object':
|
|
113
|
+
// The model's answer is never stored — only the envelope that proves the
|
|
114
|
+
// run happened and what it cost (nothing the caller did not already send).
|
|
115
|
+
return result ? {
|
|
116
|
+
finishReason: result.finishReason ?? null,
|
|
117
|
+
usage: result.usage ?? null,
|
|
118
|
+
tools: Array.isArray(result.toolCalls)
|
|
119
|
+
? result.toolCalls.map((call: any) => call?.name).filter(Boolean)
|
|
120
|
+
: [],
|
|
121
|
+
steps: Array.isArray(result.steps) ? result.steps.length : null,
|
|
122
|
+
} : null
|
|
123
|
+
default:
|
|
124
|
+
// Anything unknown: a document must never be stored.
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
IDS_MAX,
|
|
131
|
+
auditResultsMode,
|
|
132
|
+
summarizeAuditResult,
|
|
133
|
+
}
|
package/lib/define.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { Service } from "../types/service";
|
|
|
8
8
|
import type { WebSocketHandler } from "../types/websocket";
|
|
9
9
|
import type { TenantMiddlewareConfig, GlobalMiddlewareConfig } from "../types/middleware";
|
|
10
10
|
import type { McpTool, McpResource } from "../types/mcp";
|
|
11
|
+
import type { Lifecycle } from "../types/lifecycle";
|
|
12
|
+
import type { VarDefinition } from "../types/vars";
|
|
11
13
|
import { file } from "bun";
|
|
12
14
|
function Server(config: ServerConfig) {
|
|
13
15
|
config.server = {
|
|
@@ -100,6 +102,21 @@ function Script(config: Script) {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
function Lifecycle(config: Lifecycle): Lifecycle {
|
|
106
|
+
return {
|
|
107
|
+
...config,
|
|
108
|
+
enabled: config.enabled ?? true,
|
|
109
|
+
_isLifecycle_: true,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function Vars(config: VarDefinition): VarDefinition {
|
|
114
|
+
return {
|
|
115
|
+
...config,
|
|
116
|
+
_isVars_: true,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
|
|
104
121
|
function Route(route: Route): Route {
|
|
105
122
|
return {
|
|
@@ -142,14 +159,54 @@ function Middleware(config: GlobalMiddlewareConfig): GlobalMiddlewareConfig {
|
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
import type { WorkflowDefinition } from "../types/workflow";
|
|
162
|
+
import type { AgentConfig, AgentTool, AgentToolDefinition } from "../types/agent";
|
|
145
163
|
|
|
146
164
|
export function Workflow<TData = any>(workflow: WorkflowDefinition<TData>): WorkflowDefinition<TData> {
|
|
147
165
|
return {
|
|
148
166
|
...workflow,
|
|
167
|
+
enabled: workflow.enabled ?? true,
|
|
149
168
|
_isWorkflow_: true,
|
|
150
169
|
}
|
|
151
170
|
}
|
|
152
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Declares an LLM agent — `{tenant.dir}/agents/**\/*.agent.ts`.
|
|
174
|
+
*
|
|
175
|
+
* `id` and `description` are **required**: the id identifies it (and looks it up),
|
|
176
|
+
* the description says what it is for — it is served by `info` and read by humans.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* export default define.Agent({
|
|
181
|
+
* id: 'weather',
|
|
182
|
+
* description: 'Answers weather questions with a live forecast.',
|
|
183
|
+
* instructions: 'You are a concise weather assistant.',
|
|
184
|
+
* provider: { model: 'gpt-4o-mini', compatible: 'openai' },
|
|
185
|
+
* tools: { forecast },
|
|
186
|
+
* })
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
function Agent(config: AgentConfig & { id: string; description: string }): AgentConfig {
|
|
190
|
+
return {
|
|
191
|
+
...config,
|
|
192
|
+
enabled: config.enabled ?? true,
|
|
193
|
+
_isAgent_: true,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Declares an agent tool — `define.Tool({ id, description, inputSchema, execute })`.
|
|
199
|
+
* The `id` is **required** (it is the name the model calls).
|
|
200
|
+
* A `define.McpTool` is accepted by an agent as-is.
|
|
201
|
+
*/
|
|
202
|
+
function Tool(tool: AgentToolDefinition): AgentToolDefinition {
|
|
203
|
+
return {
|
|
204
|
+
...tool,
|
|
205
|
+
enabled: tool.enabled ?? true,
|
|
206
|
+
_isTool_: true,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
153
210
|
|
|
154
211
|
|
|
155
212
|
export const define = {
|
|
@@ -167,5 +224,9 @@ export const define = {
|
|
|
167
224
|
Middleware,
|
|
168
225
|
WebSocket,
|
|
169
226
|
McpTool,
|
|
170
|
-
McpResource
|
|
227
|
+
McpResource,
|
|
228
|
+
Lifecycle,
|
|
229
|
+
Vars,
|
|
230
|
+
Agent,
|
|
231
|
+
Tool,
|
|
171
232
|
}
|
package/lib/endpoints.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL of everything — one place.
|
|
3
|
+
*
|
|
4
|
+
* Every framework surface lives under a **single namespace**, `/api/:tenant_id`,
|
|
5
|
+
* and a **family** segment says what it is:
|
|
6
|
+
*
|
|
7
|
+
* | Surface | Route |
|
|
8
|
+
* | --- | --- |
|
|
9
|
+
* | Collections (CRUD, actions, login…) | `POST /api/:tenant_id/collections/:collection/:action` |
|
|
10
|
+
* | Services | `POST /api/:tenant_id/services/:service/:action` |
|
|
11
|
+
* | Variables | `POST /api/:tenant_id/vars/:action` |
|
|
12
|
+
* | Upload | `POST /api/:tenant_id/upload/:collection` |
|
|
13
|
+
* | Files (serve, delete) | `GET\|DELETE /api/:tenant_id/files/:collection/:file` |
|
|
14
|
+
* | Agents | `POST /api/:tenant_id/agents/:agent/:action` |
|
|
15
|
+
* | MCP (tools & resources) | `GET\|POST /api/:tenant_id/mcp` |
|
|
16
|
+
*
|
|
17
|
+
* Two reasons for the shape. A tenant's own routes (`routes: { prefix: '/api/v1' }`)
|
|
18
|
+
* are mounted **before** this one, so they always win — and nothing is ambiguous:
|
|
19
|
+
* a collection may be called `services` or `login` without ever colliding with the
|
|
20
|
+
* family segment, since a collection always sits under `/collections/`.
|
|
21
|
+
*
|
|
22
|
+
* `patterns` is what the server registers (Hono patterns); `endpoints` is what a
|
|
23
|
+
* client builds — the two are read together, so a path is never written twice.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The family segments — the component right after the tenant id. */
|
|
27
|
+
const FAMILIES = ["collections", "services", "vars", "upload", "files", "agents", "mcp"] as const;
|
|
28
|
+
|
|
29
|
+
const patterns = {
|
|
30
|
+
collection: "/api/:tenant_id/collections/:collection/:action",
|
|
31
|
+
/** The login action of an auth-enabled collection — its own rate limit. */
|
|
32
|
+
login: "/api/:tenant_id/collections/:collection/login",
|
|
33
|
+
service: "/api/:tenant_id/services/:service/:action",
|
|
34
|
+
vars: "/api/:tenant_id/vars/:action",
|
|
35
|
+
upload: "/api/:tenant_id/upload/:collection",
|
|
36
|
+
file: "/api/:tenant_id/files/:collection/:file",
|
|
37
|
+
agent: "/api/:tenant_id/agents/:agent/:action",
|
|
38
|
+
mcp: "/api/:tenant_id/mcp",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** One path segment, encoded. */
|
|
42
|
+
function segment(value: string): string {
|
|
43
|
+
return encodeURIComponent(String(value));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const endpoints = {
|
|
47
|
+
/** Everything of a collection — `find`, `insertOne`, `login`, a custom action… */
|
|
48
|
+
collection: (tenant: string, collection: string, action: string) =>
|
|
49
|
+
`/api/${segment(tenant)}/collections/${segment(collection)}/${segment(action)}`,
|
|
50
|
+
|
|
51
|
+
service: (tenant: string, service: string, action: string) =>
|
|
52
|
+
`/api/${segment(tenant)}/services/${segment(service)}/${segment(action)}`,
|
|
53
|
+
|
|
54
|
+
/** Variables — the namespace and the key travel in the body. */
|
|
55
|
+
vars: (tenant: string, action: string) =>
|
|
56
|
+
`/api/${segment(tenant)}/vars/${segment(action)}`,
|
|
57
|
+
|
|
58
|
+
upload: (tenant: string, collection: string) =>
|
|
59
|
+
`/api/${segment(tenant)}/upload/${segment(collection)}`,
|
|
60
|
+
|
|
61
|
+
/** One file of a file collection — the document id, or its filename. */
|
|
62
|
+
file: (tenant: string, collection: string, file: string) =>
|
|
63
|
+
`/api/${segment(tenant)}/files/${segment(collection)}/${segment(file)}`,
|
|
64
|
+
|
|
65
|
+
agent: (tenant: string, agent: string, action: string) =>
|
|
66
|
+
`/api/${segment(tenant)}/agents/${segment(agent)}/${segment(action)}`,
|
|
67
|
+
|
|
68
|
+
mcp: (tenant: string) => `/api/${segment(tenant)}/mcp`,
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is this a segment the framework owns? A tenant route mounted at
|
|
73
|
+
* `/api/:tenant_id/<family>/…` is registered **first** and would shadow the
|
|
74
|
+
* framework API silently — the route loader warns about it.
|
|
75
|
+
*/
|
|
76
|
+
function isReservedFamily(name: string): boolean {
|
|
77
|
+
return (FAMILIES as readonly string[]).includes(name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { endpoints, FAMILIES, isReservedFamily, patterns, segment };
|
package/lib/files.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { getFileCollection } from "../database/file";
|
|
2
|
+
import { getTenant } from "../database/tenant";
|
|
3
|
+
import { writeDeleteMarkers } from "../database/deleteLog";
|
|
4
|
+
import { toBson } from "../utils/func";
|
|
2
5
|
import { useRest } from "../database/rest";
|
|
3
6
|
import { cfg } from "../server/config";
|
|
4
7
|
import { AppError } from "./error";
|
|
8
|
+
import { endpoints } from "./endpoints";
|
|
5
9
|
import { S3Client } from "bun";
|
|
6
10
|
import type { FileCollection } from "../types/file";
|
|
7
11
|
import path from "path";
|
|
@@ -13,6 +17,10 @@ import { ObjectId } from "mongodb";
|
|
|
13
17
|
|
|
14
18
|
export type FileResult = {
|
|
15
19
|
_id: string;
|
|
20
|
+
/** ISO string — set on upload */
|
|
21
|
+
createdAt: string;
|
|
22
|
+
/** ISO string — refreshed when the metadata is completed */
|
|
23
|
+
updatedAt: string;
|
|
16
24
|
_file: {
|
|
17
25
|
filename: string;
|
|
18
26
|
name: string;
|
|
@@ -54,7 +62,7 @@ export function createDiskStorage(baseDir?: string): FileStorage {
|
|
|
54
62
|
const filename = `${meta.id}${ext}`;
|
|
55
63
|
const filepath = path.join(dir, filename);
|
|
56
64
|
const buffer = await file.arrayBuffer();
|
|
57
|
-
//
|
|
65
|
+
// Flaw 17: 'wx' — fail if file already exists (no silent overwrite)
|
|
58
66
|
await fs.writeFile(filepath, new Uint8Array(buffer), { flag: 'wx' });
|
|
59
67
|
const outPath = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
60
68
|
return { path: outPath, size: buffer.byteLength };
|
|
@@ -135,7 +143,7 @@ async function transformImage(
|
|
|
135
143
|
const format = options.format || 'webp';
|
|
136
144
|
const quality = options.quality ?? 80;
|
|
137
145
|
|
|
138
|
-
//
|
|
146
|
+
// Flaw 20: validate params (reject NaN / out-of-range)
|
|
139
147
|
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
|
|
140
148
|
throw new AppError('Invalid quality parameter', { code: 'INVALID_TRANSFORM', status: 400 });
|
|
141
149
|
}
|
|
@@ -144,7 +152,7 @@ async function transformImage(
|
|
|
144
152
|
throw new AppError('Invalid width/height parameter', { code: 'INVALID_TRANSFORM', status: 400 });
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
//
|
|
155
|
+
// Convert a ReadableStream to a Uint8Array if needed
|
|
148
156
|
let buffer: Uint8Array;
|
|
149
157
|
if (input instanceof Uint8Array) {
|
|
150
158
|
buffer = input;
|
|
@@ -165,13 +173,13 @@ async function transformImage(
|
|
|
165
173
|
}
|
|
166
174
|
}
|
|
167
175
|
|
|
168
|
-
//
|
|
176
|
+
// Flaw 20: only transform real images (magic bytes check)
|
|
169
177
|
const detected = detectMimeFromBuffer(buffer);
|
|
170
178
|
if (!detected || !detected.startsWith('image/')) {
|
|
171
179
|
throw new AppError('Not an image file', { code: 'NOT_AN_IMAGE', status: 400 });
|
|
172
180
|
}
|
|
173
181
|
|
|
174
|
-
//
|
|
182
|
+
// Use Bun.Image (native, zero dependencies)
|
|
175
183
|
const img = new Bun.Image(buffer);
|
|
176
184
|
|
|
177
185
|
if (options.width || options.height) {
|
|
@@ -181,7 +189,7 @@ async function transformImage(
|
|
|
181
189
|
});
|
|
182
190
|
}
|
|
183
191
|
|
|
184
|
-
//
|
|
192
|
+
// Encode in the requested format
|
|
185
193
|
let out: Blob;
|
|
186
194
|
switch (format) {
|
|
187
195
|
case 'jpeg':
|
|
@@ -236,6 +244,7 @@ export function getStorageForCollection(slug: string, tenant_id: string): FileSt
|
|
|
236
244
|
* Handle a file upload for a given collection.
|
|
237
245
|
*/
|
|
238
246
|
export async function handleUpload(options: UploadOptions): Promise<FileResult> {
|
|
247
|
+
const startedAt = Date.now();
|
|
239
248
|
const { collection, tenant_id, file, data } = options;
|
|
240
249
|
|
|
241
250
|
const col = getFileCollection(collection, tenant_id);
|
|
@@ -253,7 +262,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
253
262
|
});
|
|
254
263
|
}
|
|
255
264
|
|
|
256
|
-
//
|
|
265
|
+
// Flaw 4: validate magic bytes — don't trust client-declared Content-Type
|
|
257
266
|
const fullBuffer = new Uint8Array(await file.arrayBuffer());
|
|
258
267
|
const probe = fullBuffer.subarray(0, 4096);
|
|
259
268
|
const detected = detectMimeFromBuffer(probe);
|
|
@@ -283,6 +292,9 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
283
292
|
});
|
|
284
293
|
}
|
|
285
294
|
|
|
295
|
+
// Timestamps (ISO strings) — stored and returned in the response
|
|
296
|
+
const now = new Date()
|
|
297
|
+
|
|
286
298
|
// Insert metadata first to get the MongoDB auto-generated ObjectId
|
|
287
299
|
let _id: string;
|
|
288
300
|
try {
|
|
@@ -290,6 +302,8 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
290
302
|
// Form-data / JSON always sends IDs as strings, but MongoDB expects
|
|
291
303
|
// ObjectId for fields that reference other collections.
|
|
292
304
|
const insertDoc: Record<string, any> = {
|
|
305
|
+
createdAt: now,
|
|
306
|
+
updatedAt: now,
|
|
293
307
|
_file: {
|
|
294
308
|
filename: '',
|
|
295
309
|
name: file.name,
|
|
@@ -325,7 +339,13 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
325
339
|
}
|
|
326
340
|
}
|
|
327
341
|
const rest = new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false });
|
|
328
|
-
|
|
342
|
+
// Convert the custom fields exactly like a regular collection insert
|
|
343
|
+
// (ISO date strings → Date, hex ids → ObjectId, …) so file metadata matches
|
|
344
|
+
// the collections' storage types. `_file` is framework-generated and must
|
|
345
|
+
// stay untouched (a filename could otherwise look like a date or an ObjectId).
|
|
346
|
+
const { _file, ...fields } = insertDoc;
|
|
347
|
+
const stored = { _file, ...toBson<Record<string, any>>(fields, { col } as any) };
|
|
348
|
+
const result = await rest.db.collection(collection).insertOne(stored as any);
|
|
329
349
|
_id = String(result.insertedId);
|
|
330
350
|
} catch (err) {
|
|
331
351
|
throw new AppError('Failed to save file metadata', {
|
|
@@ -341,12 +361,12 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
341
361
|
const { path: filepath, size } = await storage.save(tenant_id, collection, file, { id: _id, mimetype, subpath });
|
|
342
362
|
|
|
343
363
|
// Update MongoDB with actual file info
|
|
344
|
-
const url =
|
|
364
|
+
const url = endpoints.file(tenant_id, collection, filename);
|
|
345
365
|
try {
|
|
346
366
|
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
347
367
|
await rest.db.collection(collection).updateOne(
|
|
348
368
|
{ _id: new ObjectId(_id) },
|
|
349
|
-
{ $set: { '_file.filename': filename, '_file.size': size, '_file.url': url } },
|
|
369
|
+
{ $set: { '_file.filename': filename, '_file.size': size, '_file.url': url, updatedAt: new Date() } },
|
|
350
370
|
);
|
|
351
371
|
} catch (err) {
|
|
352
372
|
await storage.delete(tenant_id, collection, _id, filename, subpath).catch(() => {});
|
|
@@ -362,8 +382,26 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
362
382
|
});
|
|
363
383
|
}
|
|
364
384
|
|
|
385
|
+
// Audit the upload — the file metadata and the generated id, never the stored
|
|
386
|
+
// document (see the Audit page: parameters only). `internal` is taken from the
|
|
387
|
+
// request context, so an HTTP upload is logged as such.
|
|
388
|
+
try {
|
|
389
|
+
await new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false }).audit.log({
|
|
390
|
+
action: 'upload',
|
|
391
|
+
collection,
|
|
392
|
+
input: {
|
|
393
|
+
file: { name: file.name, mimetype, size },
|
|
394
|
+
fields: Object.keys(data ?? {}),
|
|
395
|
+
},
|
|
396
|
+
result: { _id },
|
|
397
|
+
duration: Date.now() - startedAt,
|
|
398
|
+
});
|
|
399
|
+
} catch { /* auditing must never fail an upload */ }
|
|
400
|
+
|
|
365
401
|
return {
|
|
366
402
|
_id,
|
|
403
|
+
createdAt: now.toISOString(),
|
|
404
|
+
updatedAt: now.toISOString(),
|
|
367
405
|
_file: {
|
|
368
406
|
filename,
|
|
369
407
|
name: file.name,
|
|
@@ -391,7 +429,7 @@ export async function handleServe(
|
|
|
391
429
|
const stream = await storage.getStream(tenant_id, collection, fileId, filename, subpath);
|
|
392
430
|
if (!stream) return null;
|
|
393
431
|
|
|
394
|
-
//
|
|
432
|
+
// Flaw 10: serve the MIME validated at upload time (content-checked),
|
|
395
433
|
// not re-derived from the extension.
|
|
396
434
|
let mimetype: string | undefined;
|
|
397
435
|
try {
|
|
@@ -412,7 +450,7 @@ export async function handleServe(
|
|
|
412
450
|
return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
|
|
413
451
|
}
|
|
414
452
|
|
|
415
|
-
//
|
|
453
|
+
// Flaw 4: SVG is served as an attachment (never inline) to prevent stored XSS
|
|
416
454
|
if (isSvg) {
|
|
417
455
|
return { stream, mimetype, attachment: true };
|
|
418
456
|
}
|
|
@@ -477,6 +515,7 @@ export async function handleDelete(
|
|
|
477
515
|
collection: string,
|
|
478
516
|
fileId: string,
|
|
479
517
|
): Promise<void> {
|
|
518
|
+
const startedAt = Date.now();
|
|
480
519
|
const storage = getStorageForCollection(collection, tenant_id);
|
|
481
520
|
const col = getFileCollection(collection, tenant_id);
|
|
482
521
|
const subpath = col?.storage?.path || undefined;
|
|
@@ -511,6 +550,21 @@ export async function handleDelete(
|
|
|
511
550
|
await rest.db.collection(collection).deleteOne({
|
|
512
551
|
_id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any,
|
|
513
552
|
});
|
|
553
|
+
// This path bypasses the collection hooks — record a tombstone explicitly so the
|
|
554
|
+
// deletion propagates when the file collection opted into replication.
|
|
555
|
+
if (col?.replication?.enabled) {
|
|
556
|
+
const db = getTenant(tenant_id)?.database?.db;
|
|
557
|
+
if (db) await writeDeleteMarkers(db, tenant_id, collection, [fileId]);
|
|
558
|
+
}
|
|
559
|
+
// Audited like any other delete: the id and the stored filename, nothing else.
|
|
560
|
+
// (`internal` is taken from the request context, so an HTTP call is logged as such.)
|
|
561
|
+
await rest.audit.log({
|
|
562
|
+
action: 'deleteFile',
|
|
563
|
+
collection,
|
|
564
|
+
input: { id: fileId, filename },
|
|
565
|
+
result: { deleted: true },
|
|
566
|
+
duration: Date.now() - startedAt,
|
|
567
|
+
});
|
|
514
568
|
} catch (err: any) {
|
|
515
569
|
console.error('Failed to delete file metadata:', err?.message || err);
|
|
516
570
|
}
|