@anteros/core 0.0.44 → 0.0.47
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/LICENSE +21 -0
- package/README.md +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- package/utils/logger.ts +125 -33
package/lib/zip.ts
CHANGED
|
@@ -23,9 +23,17 @@ const SIGNATURE = {
|
|
|
23
23
|
/** Zip entries are never larger than this in a document we read — sanity guard. */
|
|
24
24
|
const MAX_EOCD_SCAN = 65_535 + 22; // the folder comment cap + the record itself
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Decompressed-entry ceiling (#41) — mirrors `MAX_FILE_BYTES` (attachment.ts) and
|
|
28
|
+
* stops a deflate bomb: a 20 KB archive can otherwise expand to gigabytes.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_ENTRY_BYTES = 10 * 1024 * 1024;
|
|
31
|
+
|
|
26
32
|
type Entry = {
|
|
27
33
|
method: number;
|
|
28
34
|
compressedSize: number;
|
|
35
|
+
/** Declared size after inflation — checked before we allocate anything. */
|
|
36
|
+
uncompressedSize: number;
|
|
29
37
|
localOffset: number;
|
|
30
38
|
};
|
|
31
39
|
|
|
@@ -67,17 +75,18 @@ function openZip(buffer: Uint8Array): ZipArchive {
|
|
|
67
75
|
|
|
68
76
|
const method = view.getUint16(offset + 10, true);
|
|
69
77
|
const compressedSize = view.getUint32(offset + 20, true);
|
|
78
|
+
const uncompressedSize = view.getUint32(offset + 24, true);
|
|
70
79
|
const nameLength = view.getUint16(offset + 28, true);
|
|
71
80
|
const extraLength = view.getUint16(offset + 30, true);
|
|
72
81
|
const commentLength = view.getUint16(offset + 32, true);
|
|
73
82
|
const localOffset = view.getUint32(offset + 42, true);
|
|
74
83
|
|
|
75
|
-
if (compressedSize === 0xffffffff || localOffset === 0xffffffff) {
|
|
84
|
+
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) {
|
|
76
85
|
throw bad("ZIP64 archives are not supported");
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
const name = new TextDecoder().decode(buffer.subarray(offset + 46, offset + 46 + nameLength));
|
|
80
|
-
entries.set(name, { method, compressedSize, localOffset });
|
|
89
|
+
entries.set(name, { method, compressedSize, uncompressedSize, localOffset });
|
|
81
90
|
offset += 46 + nameLength + extraLength + commentLength;
|
|
82
91
|
}
|
|
83
92
|
|
|
@@ -98,9 +107,20 @@ function openZip(buffer: Uint8Array): ZipArchive {
|
|
|
98
107
|
const data = buffer.subarray(start, start + entry.compressedSize);
|
|
99
108
|
if (start + entry.compressedSize > buffer.length) throw bad(`truncated entry '${name}'`);
|
|
100
109
|
|
|
110
|
+
// The declared size is a first guard — before inflating anything.
|
|
111
|
+
if (entry.uncompressedSize > MAX_ENTRY_BYTES) {
|
|
112
|
+
throw bad(`entry '${name}' expands beyond the ${MAX_ENTRY_BYTES / 1024 / 1024}MB limit`);
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
// 0 = stored, 8 = deflate — the only two methods Office writes
|
|
102
116
|
if (entry.method === 0) return data;
|
|
103
|
-
if (entry.method === 8)
|
|
117
|
+
if (entry.method === 8) {
|
|
118
|
+
try {
|
|
119
|
+
return new Uint8Array(inflateRawSync(data, { maxOutputLength: MAX_ENTRY_BYTES }));
|
|
120
|
+
} catch {
|
|
121
|
+
throw bad(`entry '${name}' expands beyond the ${MAX_ENTRY_BYTES / 1024 / 1024}MB limit`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
104
124
|
throw bad(`unsupported compression method ${entry.method} for '${name}'`);
|
|
105
125
|
},
|
|
106
126
|
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anteros/core",
|
|
3
3
|
"module": "index.ts",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.47",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Anteros Core",
|
|
7
|
+
"license": "MIT",
|
|
7
8
|
"private": false,
|
|
8
9
|
"packageManager": "bun",
|
|
9
10
|
"engines": {
|
|
@@ -22,16 +23,14 @@
|
|
|
22
23
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
23
24
|
"@socket.io/bun-engine": "0.1.1",
|
|
24
25
|
"bentocache": "1.6.1",
|
|
25
|
-
"boxen": "^
|
|
26
|
-
"dayjs": "1.11.21",
|
|
26
|
+
"boxen": "^9.0.0",
|
|
27
27
|
"hono": "^4.13.1",
|
|
28
28
|
"ioredis": "6.0.0",
|
|
29
|
-
"joi": "18.
|
|
29
|
+
"joi": "^18.3.0",
|
|
30
30
|
"jose": "6.2.8",
|
|
31
31
|
"mongodb": "7.5.0",
|
|
32
32
|
"socket.io": "4.8.3",
|
|
33
33
|
"tcp-port-used": "^1.0.3",
|
|
34
|
-
"ufo": "1.6.4",
|
|
35
34
|
"zod": "^4.4.3"
|
|
36
35
|
}
|
|
37
36
|
}
|
package/server/agents.ts
CHANGED
|
@@ -167,6 +167,9 @@ function presentResult(result: AgentGenerateResult) {
|
|
|
167
167
|
...(result.reasoning ? { reasoning: result.reasoning } : {}),
|
|
168
168
|
toolCalls: result.toolCalls,
|
|
169
169
|
toolResults: result.toolResults,
|
|
170
|
+
toolStats: result.toolStats,
|
|
171
|
+
...(result.model ? { model: result.model } : {}),
|
|
172
|
+
...(result.budget ? { budget: result.budget } : {}),
|
|
170
173
|
steps: result.steps,
|
|
171
174
|
usage: result.usage,
|
|
172
175
|
finishReason: result.finishReason,
|
|
@@ -256,6 +259,9 @@ function streamResponse(
|
|
|
256
259
|
object: await stream.object,
|
|
257
260
|
toolCalls: await stream.toolCalls,
|
|
258
261
|
toolResults: await stream.toolResults,
|
|
262
|
+
toolStats: await stream.toolStats,
|
|
263
|
+
model: await stream.model,
|
|
264
|
+
budget: await stream.budget,
|
|
259
265
|
steps: await stream.steps,
|
|
260
266
|
usage: await stream.usage,
|
|
261
267
|
finishReason: await stream.finishReason,
|
|
@@ -299,6 +305,62 @@ function streamResponse(
|
|
|
299
305
|
});
|
|
300
306
|
}
|
|
301
307
|
|
|
308
|
+
/** A file a caller may attach over HTTP — content only, never a server path. */
|
|
309
|
+
type HttpAttachment = {
|
|
310
|
+
name?: string;
|
|
311
|
+
mimeType?: string;
|
|
312
|
+
data: string;
|
|
313
|
+
encoding?: "base64" | "utf8";
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Validate the `files` field of the HTTP body (issue #29).
|
|
318
|
+
*
|
|
319
|
+
* Over HTTP a file is **content** — `{ name?, mimeType?, data, encoding? }`, base64
|
|
320
|
+
* or UTF-8 — never a server path. Path attachments are a server-side/in-process
|
|
321
|
+
* feature: accepting one here let any caller read a file the process could read
|
|
322
|
+
* (`files: ["../../../.env"]`), whose content then went to the model provider.
|
|
323
|
+
*/
|
|
324
|
+
function httpAttachments(input: unknown): HttpAttachment[] | undefined {
|
|
325
|
+
if (input === undefined) return undefined;
|
|
326
|
+
if (!Array.isArray(input)) {
|
|
327
|
+
throw new AppError("`files` must be an array of { name, data, encoding } objects", {
|
|
328
|
+
status: 400, code: "AGENT_FILES_INVALID",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return input.map((entry) => {
|
|
333
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
334
|
+
throw new AppError("Each `files` entry must be an object — a server path is not accepted over HTTP", {
|
|
335
|
+
status: 400, code: "AGENT_FILES_INVALID",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const { name, mimeType, data, encoding } = entry as Record<string, unknown>;
|
|
340
|
+
if (typeof data !== "string") {
|
|
341
|
+
throw new AppError("Each `files` entry needs a `data` string (base64, or UTF-8 with `encoding: 'utf8'`)", {
|
|
342
|
+
status: 400, code: "AGENT_FILES_INVALID",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (name !== undefined && typeof name !== "string") {
|
|
346
|
+
throw new AppError("A `files` entry `name` must be a string", { status: 400, code: "AGENT_FILES_INVALID" });
|
|
347
|
+
}
|
|
348
|
+
if (mimeType !== undefined && typeof mimeType !== "string") {
|
|
349
|
+
throw new AppError("A `files` entry `mimeType` must be a string", { status: 400, code: "AGENT_FILES_INVALID" });
|
|
350
|
+
}
|
|
351
|
+
if (encoding !== undefined && encoding !== "base64" && encoding !== "utf8") {
|
|
352
|
+
throw new AppError("A `files` entry `encoding` must be 'base64' or 'utf8'", { status: 400, code: "AGENT_FILES_INVALID" });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
...(name !== undefined ? { name } : {}),
|
|
357
|
+
...(mimeType !== undefined ? { mimeType } : {}),
|
|
358
|
+
data,
|
|
359
|
+
...(encoding !== undefined ? { encoding } : {}),
|
|
360
|
+
} as HttpAttachment;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
302
364
|
function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
303
365
|
app.post(AGENT_PREFIX, async (c) => {
|
|
304
366
|
const started = Date.now();
|
|
@@ -347,6 +409,8 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
347
409
|
// A resume carries its own conversation: no input to ask for
|
|
348
410
|
const needsInput = (action === 'generate' || action === 'stream' || action === 'object') && !body?.resume;
|
|
349
411
|
const input = needsInput ? normalizeInput(body) : undefined;
|
|
412
|
+
// Files are content over HTTP — a server path is refused before the run
|
|
413
|
+
const files = httpAttachments(body?.files);
|
|
350
414
|
// `memory: { thread, resource }` or the flat `thread` / `resource`
|
|
351
415
|
const thread = resolveThread(body);
|
|
352
416
|
const resource = resolveResource(body);
|
|
@@ -360,6 +424,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
360
424
|
thread,
|
|
361
425
|
// The deprecated alias, so a rule written against it keeps working
|
|
362
426
|
threadId: thread,
|
|
427
|
+
tenant: tenant_id,
|
|
363
428
|
});
|
|
364
429
|
|
|
365
430
|
const callOptions: AgentCallOptions = {
|
|
@@ -372,7 +437,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
372
437
|
...(body?.messages ? { messages: body.messages as AgentInput } : {}),
|
|
373
438
|
// Stop the run when the client disconnects (no tokens past the wire)
|
|
374
439
|
signal: c.req.raw.signal,
|
|
375
|
-
...(
|
|
440
|
+
...(files?.length ? { files } : {}),
|
|
376
441
|
...(body?.resume ? { resume: body.resume } : {}),
|
|
377
442
|
...(readOnly ? { memory: { readOnly: true } } : {}),
|
|
378
443
|
};
|
package/server/api.ts
CHANGED
|
@@ -130,6 +130,235 @@ function omitAtPath(node: any, path: string[], keys: (string | RegExp)[]): any {
|
|
|
130
130
|
return { ...node, [head]: rest.length ? omitAtPath(node[head], rest, keys) : func.omit(node[head], keys) };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// ─── External-API guards (issues #31, #32, #33) ───────────────────────────
|
|
134
|
+
//
|
|
135
|
+
// The internal `rest` keeps its full surface (scripts and services may compute,
|
|
136
|
+
// replace, etc.); these checks only protect what a **client** sends to the HTTP
|
|
137
|
+
// API.
|
|
138
|
+
|
|
139
|
+
const RESERVED_UPLOAD_FIELDS = new Set(["_id", "_file", "createdAt", "updatedAt"]);
|
|
140
|
+
|
|
141
|
+
/** Framework-managed upload metadata cannot be named by the client (#31). */
|
|
142
|
+
function assertUploadMetadata(data: Record<string, any>): void {
|
|
143
|
+
for (const key of Object.keys(data)) {
|
|
144
|
+
if (RESERVED_UPLOAD_FIELDS.has(key) || key.startsWith("$")) {
|
|
145
|
+
throw new AppError(`Metadata field '${key}' is reserved`, { status: 400, code: "UPLOAD_FIELD_RESERVED" });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A plain object (not an array, not null) — used to validate client bodies (#24). */
|
|
151
|
+
function requirePlainObject(value: unknown, label: string): void {
|
|
152
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
153
|
+
throw new AppError(`\`${label}\` must be an object`, { status: 400, code: "INVALID_BODY" });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function requireStringArray(value: unknown, label: string): void {
|
|
158
|
+
if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || !id.trim())) {
|
|
159
|
+
throw new AppError(`\`${label}\` must be an array of ids`, { status: 400, code: "INVALID_BODY" });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A client `id`/`_id` must be a string — an operator object (`$ne`) would match anything (#47). */
|
|
164
|
+
function requireId(body: any): void {
|
|
165
|
+
const id = body?.id ?? body?._id;
|
|
166
|
+
if (typeof id !== "string" || !id.trim() || id.startsWith("$")) {
|
|
167
|
+
throw new AppError("`id` must be a string", { status: 400, code: "INVALID_ID" });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Safe cookie defaults (overridable) — the doc promises HttpOnly/SameSite; enforce them (#48). */
|
|
172
|
+
function cookieDefaults(options: any = {}): Record<string, any> {
|
|
173
|
+
const base: Record<string, any> = { httpOnly: true, sameSite: "lax", path: "/" };
|
|
174
|
+
if ((Bun.env.NODE_ENV ?? process.env.NODE_ENV) === "production") base.secure = true;
|
|
175
|
+
return { ...base, ...(options ?? {}) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Update operators a client may use (external API). `$rename` is deliberately absent (see #43). */
|
|
179
|
+
const CLIENT_UPDATE_OPERATORS = new Set([
|
|
180
|
+
"$set", "$unset", "$inc", "$mul", "$min", "$max", "$currentDate",
|
|
181
|
+
"$push", "$addToSet", "$pull", "$pullAll", "$pop",
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* External mutations must be an allow-listed operator document (#32):
|
|
186
|
+
* MongoDB refuses replacement documents in `findAndModify` (500 today), and the
|
|
187
|
+
* operators other than `$set` escaped Joi validation and `readOnlyFields`.
|
|
188
|
+
*/
|
|
189
|
+
function assertOperatorUpdate(update: unknown, col: any): void {
|
|
190
|
+
if (!update || typeof update !== "object" || Array.isArray(update)) {
|
|
191
|
+
throw new AppError("`update` must be a MongoDB update document", { status: 400, code: "INVALID_UPDATE" });
|
|
192
|
+
}
|
|
193
|
+
const operators = Object.keys(update);
|
|
194
|
+
if (!operators.length || !operators.every((operator) => CLIENT_UPDATE_OPERATORS.has(operator))) {
|
|
195
|
+
throw new AppError("`update` must only use supported MongoDB update operators", { status: 400, code: "INVALID_UPDATE" });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const readOnly = (col?.api?.readOnlyFields ?? []).map((field: string | RegExp) =>
|
|
199
|
+
typeof field === "string" ? (name: string) => name === field : (name: string) => field.test(name),
|
|
200
|
+
);
|
|
201
|
+
const required = new Set((col?.fields ?? []).filter((field: any) => field.required).map((field: any) => field.name));
|
|
202
|
+
|
|
203
|
+
for (const operator of operators) {
|
|
204
|
+
const value = (update as Record<string, any>)[operator];
|
|
205
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
206
|
+
for (const target of Object.keys(value)) {
|
|
207
|
+
if (readOnly.some((match) => match(target))) {
|
|
208
|
+
throw new AppError(`Field '${target}' is read-only`, { status: 400, code: "READONLY_FIELD" });
|
|
209
|
+
}
|
|
210
|
+
if (operator === "$unset" && required.has(target)) {
|
|
211
|
+
throw new AppError(`Field '${target}' is required`, { status: 400, code: "INVALID_UPDATE" });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
type PrivateMatcher = (name: string) => boolean;
|
|
218
|
+
|
|
219
|
+
/** `privateFields` (`string` | `RegExp`) as name matchers. */
|
|
220
|
+
function privateMatchers(col: any): PrivateMatcher[] {
|
|
221
|
+
return (col?.api?.privateFields ?? []).map((field: string | RegExp) =>
|
|
222
|
+
typeof field === "string"
|
|
223
|
+
? (name: string) => name === field
|
|
224
|
+
: (name: string) => {
|
|
225
|
+
field.lastIndex = 0; // a `g`/`y` regex would otherwise keep state between calls
|
|
226
|
+
return field.test(name);
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The join aliases a read may embed, with the private fields of their target. */
|
|
232
|
+
function collectJoinAliases(col: any, action: string, body: any): Array<{ alias: string; from: string; matchers: PrivateMatcher[] }> {
|
|
233
|
+
const joins: Array<{ alias: string; from: string; matchers: PrivateMatcher[] }> = [];
|
|
234
|
+
const targetMatchers = (slug: string) =>
|
|
235
|
+
privateMatchers(cfg.collections?.find((c) => c.slug === slug && c._tenant_ === col._tenant_));
|
|
236
|
+
|
|
237
|
+
if (action === "find" || action === "findOne") {
|
|
238
|
+
for (const inc of body?.params?.$include ?? []) {
|
|
239
|
+
if (typeof inc === "string") {
|
|
240
|
+
const field = col.fields?.find((f: any) => f.name === inc);
|
|
241
|
+
if (field?.relation?.to) joins.push({ alias: inc, from: field.relation.to, matchers: targetMatchers(field.relation.to) });
|
|
242
|
+
} else if (inc?.from) {
|
|
243
|
+
joins.push({ alias: inc.as || inc.localField, from: inc.from, matchers: targetMatchers(inc.from) });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const walk = (stages: any): void => {
|
|
249
|
+
if (!Array.isArray(stages)) return;
|
|
250
|
+
for (const stage of stages) {
|
|
251
|
+
if (!stage || typeof stage !== "object") continue;
|
|
252
|
+
const lookup = stage.$lookup ?? stage.$graphLookup;
|
|
253
|
+
if (lookup?.from && typeof lookup.as === "string") {
|
|
254
|
+
joins.push({ alias: lookup.as, from: lookup.from, matchers: targetMatchers(lookup.from) });
|
|
255
|
+
}
|
|
256
|
+
if (lookup?.pipeline) walk(lookup.pipeline);
|
|
257
|
+
if (stage.$facet && typeof stage.$facet === "object") {
|
|
258
|
+
for (const branch of Object.values(stage.$facet)) walk(branch);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
walk(body?.pipeline);
|
|
263
|
+
return joins;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A client read may only join collections it could read itself (#36): the target's
|
|
268
|
+
* `api.access` rule is evaluated for the same caller, exactly like a `find` on it.
|
|
269
|
+
* Internal collections (`_audit_`, `_workflows_`, `_locks_`) are not declared and
|
|
270
|
+
* are therefore refused. The in-process `rest` is untouched.
|
|
271
|
+
*/
|
|
272
|
+
async function assertJoinedCollectionsAllowed(
|
|
273
|
+
col: any,
|
|
274
|
+
action: string,
|
|
275
|
+
body: any,
|
|
276
|
+
c: any,
|
|
277
|
+
rest: InstanceType<typeof useRest>,
|
|
278
|
+
tenant_id: string,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
const seen = new Set<string>();
|
|
281
|
+
for (const join of collectJoinAliases(col, action, body)) {
|
|
282
|
+
if (!join.from || seen.has(join.from)) continue;
|
|
283
|
+
seen.add(join.from);
|
|
284
|
+
|
|
285
|
+
const target = cfg.collections?.find((candidate) => candidate.slug === join.from && candidate._tenant_ === col._tenant_);
|
|
286
|
+
if (target) {
|
|
287
|
+
await evaluateAccess(target.api?.access as any, 'find', rest, `lookup:${join.from}`, c, {
|
|
288
|
+
collection: join.from,
|
|
289
|
+
tenant: tenant_id,
|
|
290
|
+
lookup: true,
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fileTarget = cfg.fileCollections?.find((candidate) => candidate.slug === join.from && candidate._tenant_ === col._tenant_);
|
|
296
|
+
if (fileTarget) {
|
|
297
|
+
await evaluateAccess(fileTarget.api?.access as any, 'read', rest, `lookup:${join.from}`, c, {
|
|
298
|
+
collection: join.from,
|
|
299
|
+
tenant: tenant_id,
|
|
300
|
+
lookup: true,
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
throw new AppError(`Joining '${join.from}' is not allowed`, { status: 403, code: "LOOKUP_TARGET_FORBIDDEN" });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Expression operators that can move a field value under another name without a `$path`. */
|
|
310
|
+
const PRIVATE_MOVING_OPERATORS = new Set(["$getField", "$objectToArray", "$arrayToObject"]);
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A client read may not reach a private field **under another name** (#33):
|
|
314
|
+
* masking is by key name, so `$addFields: { leak: "$password" }` would slip
|
|
315
|
+
* through. A reference to a private field — own or through a join alias — is
|
|
316
|
+
* refused, as are the operators that can move a field without an explicit path.
|
|
317
|
+
* Internal `rest` calls are untouched.
|
|
318
|
+
*/
|
|
319
|
+
function assertNoPrivateFieldAliasing(col: any, action: string, body: any): void {
|
|
320
|
+
const own = privateMatchers(col);
|
|
321
|
+
const joins = collectJoinAliases(col, action, body);
|
|
322
|
+
const guarded = own.length > 0 || joins.some((join) => join.matchers.length > 0);
|
|
323
|
+
if (!guarded) return;
|
|
324
|
+
|
|
325
|
+
const fail = () => {
|
|
326
|
+
throw new AppError("The request references a private field", { status: 400, code: "PRIVATE_FIELD_REFERENCE" });
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const isPrivatePath = (path: string): boolean => {
|
|
330
|
+
const segments = path.split(".");
|
|
331
|
+
const head = segments[0] ?? "";
|
|
332
|
+
if (own.some((match) => match(head))) return true;
|
|
333
|
+
const join = joins.find((candidate) => candidate.alias === head);
|
|
334
|
+
return !!join && segments.length > 1 && join.matchers.some((match) => match(segments[1]!));
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const walk = (node: any): void => {
|
|
338
|
+
if (typeof node === "string") {
|
|
339
|
+
if (node.startsWith("$") && !node.startsWith("$$")) {
|
|
340
|
+
if (isPrivatePath(node.slice(1))) fail();
|
|
341
|
+
} else if (node.startsWith("$$ROOT.") && isPrivatePath(node.slice(7))) {
|
|
342
|
+
fail();
|
|
343
|
+
} else if (node.startsWith("$$CURRENT.") && isPrivatePath(node.slice(10))) {
|
|
344
|
+
fail();
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (!node || typeof node !== "object") return;
|
|
349
|
+
if (Array.isArray(node)) {
|
|
350
|
+
for (const value of node) walk(value);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
for (const [key, value] of Object.entries(node)) {
|
|
354
|
+
if (PRIVATE_MOVING_OPERATORS.has(key)) fail();
|
|
355
|
+
walk(value);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
walk(action === "aggregate" ? body?.pipeline : body?.params);
|
|
360
|
+
}
|
|
361
|
+
|
|
133
362
|
// ─── Access control ─────────────────────────────────────────────────────────
|
|
134
363
|
// `evaluateAccess` / `errorResponse` / `getAccessToken` live in `./access`
|
|
135
364
|
// (shared with the agents API).
|
|
@@ -284,7 +513,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
284
513
|
}
|
|
285
514
|
|
|
286
515
|
if (!cfg.tenants.find(t => t.id === tenant_id)) {
|
|
287
|
-
throw new AppError('
|
|
516
|
+
throw new AppError('Resource not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
288
517
|
}
|
|
289
518
|
|
|
290
519
|
// Store tenant in request context so nested useRest() calls (hooks, actions) resolve it
|
|
@@ -292,11 +521,11 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
292
521
|
|
|
293
522
|
const col = getCollection(collection, tenant_id)
|
|
294
523
|
if (!col) {
|
|
295
|
-
throw new AppError('
|
|
524
|
+
throw new AppError('Resource not found', { status: 400, code: 'COLLECTION_NOT_FOUND' });
|
|
296
525
|
}
|
|
297
526
|
|
|
298
527
|
if (!ActionsValues.includes(action as (typeof ActionsValues)[number]) && !Object.hasOwn(col?.actions ?? {}, action)) {
|
|
299
|
-
throw new AppError('
|
|
528
|
+
throw new AppError('Resource not found', { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
300
529
|
}
|
|
301
530
|
|
|
302
531
|
const fieldOrder = ['_id', ...col.fields.map(f => f.name)];
|
|
@@ -314,7 +543,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
314
543
|
|
|
315
544
|
await col.api.auth.onLogout({
|
|
316
545
|
rest, payload: body?.payload, error: fn.error, jwt: func.jwt,
|
|
317
|
-
cookies: { delete: (name: string) => cookie.deleteCookie(c, name) },
|
|
546
|
+
cookies: { delete: (name: string, opts?: any) => cookie.deleteCookie(c, name, cookieDefaults(opts)) },
|
|
318
547
|
});
|
|
319
548
|
|
|
320
549
|
await logActivity({ rest, tenant_id, action: 'logout', collection, status: 'success', input: { payload: body?.payload }, duration: Date.now() - logStart });
|
|
@@ -332,12 +561,19 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
332
561
|
if (!col?.api?.auth?.enabled) throw new AppError('Auth is not enabled', { status: 400, code: 'AUTH_NOT_ENABLED' });
|
|
333
562
|
if (!col?.api?.auth?.onLogin) throw new AppError('Auth onLogin is not defined', { status: 400, code: 'AUTH_HANDLER_NOT_DEFINED' });
|
|
334
563
|
|
|
564
|
+
// Tenant-bound tokens (#35): the claim is signed by the framework, so a
|
|
565
|
+
// token can never open another tenant's routes.
|
|
566
|
+
const loginJwt = {
|
|
567
|
+
...func.jwt,
|
|
568
|
+
sign: (payload: Record<string, unknown>, options?: any) =>
|
|
569
|
+
func.jwt.sign({ ...payload, tenant: tenant_id }, options),
|
|
570
|
+
};
|
|
335
571
|
const authResult = await col.api.auth.onLogin({
|
|
336
|
-
rest, payload: body?.payload, error: fn.error, jwt:
|
|
572
|
+
rest, payload: body?.payload, error: fn.error, jwt: loginJwt,
|
|
337
573
|
cookies: {
|
|
338
|
-
set: (name: string, value: string, opts?: any) => cookie.setCookie(c, name, value, opts),
|
|
574
|
+
set: (name: string, value: string, opts?: any) => cookie.setCookie(c, name, value, cookieDefaults(opts)),
|
|
339
575
|
get: (name: string) => cookie.getCookie(c, name),
|
|
340
|
-
delete: (name: string) => cookie.deleteCookie(c, name),
|
|
576
|
+
delete: (name: string, opts?: any) => cookie.deleteCookie(c, name, cookieDefaults(opts)),
|
|
341
577
|
},
|
|
342
578
|
});
|
|
343
579
|
|
|
@@ -361,7 +597,15 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
361
597
|
|
|
362
598
|
// ── Access control ──
|
|
363
599
|
// Flaw 7: single implementation via evaluateAccess (checks expired/forged tokens)
|
|
364
|
-
await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c);
|
|
600
|
+
await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c, { collection, tenant: tenant_id, body });
|
|
601
|
+
|
|
602
|
+
// ── External-API guards (issues #31–#33) ──
|
|
603
|
+
if (action === 'updateOne' || action === 'updateMany') {
|
|
604
|
+
assertOperatorUpdate(body?.update, col);
|
|
605
|
+
} else if (action === 'find' || action === 'findOne' || action === 'aggregate') {
|
|
606
|
+
assertNoPrivateFieldAliasing(col, action, body);
|
|
607
|
+
await assertJoinedCollectionsAllowed(col, action, body, c, rest, tenant_id);
|
|
608
|
+
}
|
|
365
609
|
|
|
366
610
|
// ── Execute action ──
|
|
367
611
|
if (col.actions?.[action]) {
|
|
@@ -372,6 +616,19 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
372
616
|
const handler = crudHandlers[action];
|
|
373
617
|
if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
374
618
|
|
|
619
|
+
// Client body shape + id guards (#24, #47) — custom actions are untouched
|
|
620
|
+
if (action === 'insertOne') {
|
|
621
|
+
requirePlainObject(body?.data, 'data');
|
|
622
|
+
} else if (action === 'insertMany') {
|
|
623
|
+
if (!Array.isArray(body?.data)) {
|
|
624
|
+
throw new AppError("`data` must be an array", { status: 400, code: "INVALID_BODY" });
|
|
625
|
+
}
|
|
626
|
+
} else if (action === 'updateOne' || action === 'deleteOne' || action === 'findOne') {
|
|
627
|
+
requireId(body);
|
|
628
|
+
} else if (action === 'updateMany' || action === 'deleteMany') {
|
|
629
|
+
requireStringArray(body?.ids ?? body?._ids, 'ids');
|
|
630
|
+
}
|
|
631
|
+
|
|
375
632
|
// Strip readOnlyFields for mutations
|
|
376
633
|
if (action === 'insertOne' || action === 'insertMany') {
|
|
377
634
|
stripReadOnlyData(col, body);
|
|
@@ -432,10 +689,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
432
689
|
// Only namespaces declared with `define.Vars` are reachable, and their
|
|
433
690
|
// `api.access` gates every action (no rules = denied).
|
|
434
691
|
const definition = getVarsDefinition(tenant_id, ns);
|
|
435
|
-
if (!definition) throw new AppError(
|
|
692
|
+
if (!definition) throw new AppError('Resource not found', { status: 400, code: 'NAMESPACE_NOT_FOUND' });
|
|
436
693
|
|
|
437
694
|
rest = new useRest({ internal: false, tenant_id });
|
|
438
|
-
await evaluateAccess(definition.api?.access as any, action, rest, `vars:${ns}`, c);
|
|
695
|
+
await evaluateAccess(definition.api?.access as any, action, rest, `vars:${ns}`, c, { namespace: ns, body, tenant: tenant_id });
|
|
439
696
|
|
|
440
697
|
const vars = rest.vars;
|
|
441
698
|
const { key, value, meta, ttl, scope, where, entries, by } = body ?? {};
|
|
@@ -465,7 +722,6 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
465
722
|
collection: `_vars_:${ns}`,
|
|
466
723
|
status: 'success',
|
|
467
724
|
input: { ns, key, scope, ttl },
|
|
468
|
-
result: ['get', 'all', 'entry', 'entries'].includes(action) ? response : undefined,
|
|
469
725
|
duration: Date.now() - logStart,
|
|
470
726
|
token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
|
|
471
727
|
});
|
|
@@ -513,7 +769,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
513
769
|
|
|
514
770
|
const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === tenant_id) as Service;
|
|
515
771
|
const { action } = c.req.param() as { action: string };
|
|
516
|
-
if (!serviceInstance) throw new AppError('
|
|
772
|
+
if (!serviceInstance) throw new AppError('Resource not found', { status: 400, code: 'SERVICE_NOT_FOUND' });
|
|
517
773
|
if (!serviceInstance.enabled) throw new AppError('Service `' + service + '` is not enabled', { status: 400, code: 'SERVICE_NOT_ENABLED' });
|
|
518
774
|
if (!serviceInstance.actions?.[action]) throw new AppError('Service `' + service + '` is not defined', { status: 400, code: 'SERVICE_NOT_DEFINED' });
|
|
519
775
|
|
|
@@ -521,7 +777,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
521
777
|
|
|
522
778
|
// Access control (same logic as checkFileAccess)
|
|
523
779
|
const serviceAccess = serviceInstance.api?.access as Record<string, boolean | Function> | undefined;
|
|
524
|
-
await evaluateAccess(serviceAccess as any, action, rest, action, c);
|
|
780
|
+
await evaluateAccess(serviceAccess as any, action, rest, action, c, { service, tenant: tenant_id, body });
|
|
525
781
|
|
|
526
782
|
// No `logActivity` here: the call is delegated to `rest.runService()`, which
|
|
527
783
|
// audits it once (`action: 'runService'`, `internal: false`, params only)
|
|
@@ -545,7 +801,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
545
801
|
|
|
546
802
|
const colUpload = getFileCollection(collection, tenant_id);
|
|
547
803
|
if (!colUpload) {
|
|
548
|
-
throw new AppError('
|
|
804
|
+
throw new AppError('Resource not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
549
805
|
}
|
|
550
806
|
await checkFileAccess(colUpload?.api?.access as any, 'upload', tenant_id, c);
|
|
551
807
|
|
|
@@ -555,13 +811,13 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
555
811
|
}
|
|
556
812
|
|
|
557
813
|
// Security: reject oversized bodies BEFORE parseBody buffers them in memory.
|
|
558
|
-
// `maxSize`
|
|
559
|
-
// fit within it (each file is
|
|
814
|
+
// `upload.maxSize` is the per-file cap AND the upload request allowance →
|
|
815
|
+
// a multi-file upload must fit within it (each file is validated in handleUpload).
|
|
560
816
|
const contentLength = Number(c.req.header('Content-Length') ?? 0);
|
|
561
|
-
const
|
|
817
|
+
const maxRequestSize = colUpload.upload?.maxSize ?? 100 * 1024 * 1024; // 100MB upload default
|
|
562
818
|
// multipart overhead (boundaries/headers) ≈ 64KB max
|
|
563
|
-
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength >
|
|
564
|
-
throw new AppError(`Request too large (max ${Math.round(
|
|
819
|
+
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxRequestSize + 64 * 1024) {
|
|
820
|
+
throw new AppError(`Request too large (max ${Math.round(maxRequestSize / 1024 / 1024)}MB)`, {
|
|
565
821
|
code: 'FILE_TOO_LARGE', status: 413,
|
|
566
822
|
});
|
|
567
823
|
}
|
|
@@ -591,6 +847,23 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
591
847
|
data[key] = list.length === 1 ? list[0] : list;
|
|
592
848
|
}
|
|
593
849
|
|
|
850
|
+
// Framework-managed fields are not client metadata (#31)
|
|
851
|
+
assertUploadMetadata(data);
|
|
852
|
+
|
|
853
|
+
// Only declared fields, and never a read-only one (#46)
|
|
854
|
+
const declared = new Set((colUpload.fields ?? []).map((field: any) => field.name));
|
|
855
|
+
const readOnlyMatchers = ((colUpload.api?.readOnlyFields ?? []) as (string | RegExp)[]).map((field) =>
|
|
856
|
+
typeof field === "string" ? (name: string) => name === field : (name: string) => field.test(name),
|
|
857
|
+
);
|
|
858
|
+
for (const key of Object.keys(data)) {
|
|
859
|
+
if (!declared.has(key)) {
|
|
860
|
+
throw new AppError(`Unknown metadata field '${key}'`, { status: 400, code: "UPLOAD_FIELD_UNKNOWN" });
|
|
861
|
+
}
|
|
862
|
+
if (readOnlyMatchers.some((match) => match(key))) {
|
|
863
|
+
throw new AppError(`Field '${key}' is read-only`, { status: 400, code: "READONLY_FIELD" });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
594
867
|
// One document per file (per-file validation: MIME, size, storage, replication).
|
|
595
868
|
// 1 file → single object (backward compatible), N files → array (SDK contract).
|
|
596
869
|
const results: FileResult[] = [];
|
|
@@ -645,7 +918,16 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
645
918
|
|
|
646
919
|
c.header('Content-Type', result.mimetype);
|
|
647
920
|
if (result.size) c.header('Content-Length', String(result.size));
|
|
648
|
-
|
|
921
|
+
// Public immutable caching only when the read rule is literally public (#49);
|
|
922
|
+
// a function rule (token-dependent) must never be cached by a shared proxy.
|
|
923
|
+
const fileAccess = colServe.api?.access as Record<string, any> | undefined;
|
|
924
|
+
const readRule = fileAccess?.read ?? fileAccess?.['*'];
|
|
925
|
+
if (readRule === true) {
|
|
926
|
+
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
927
|
+
} else {
|
|
928
|
+
c.header('Cache-Control', 'private, no-store');
|
|
929
|
+
c.header('Vary', 'Authorization, Cookie');
|
|
930
|
+
}
|
|
649
931
|
// Flaw 4: SVG served as attachment (no inline execution)
|
|
650
932
|
if (result.attachment) {
|
|
651
933
|
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|