@anteros/core 0.0.39 → 0.0.41
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 +193 -0
- package/database/collection.ts +4 -4
- package/database/deleteLog.ts +34 -0
- package/database/field.crypto.ts +1 -1
- package/database/file.ts +3 -3
- package/database/mongodbadapter.ts +321 -147
- package/database/replication.ts +780 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +6 -6
- package/index.ts +10 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +120 -0
- package/lib/define.ts +20 -1
- package/lib/files.ts +64 -11
- package/lib/lifecycle.ts +141 -0
- package/lib/redact.ts +117 -0
- package/lib/workflow.ts +1 -1
- package/package.json +1 -1
- package/server/api.ts +134 -33
- package/server/boot.ts +185 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +1 -1
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +233 -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/crypto.test.ts +17 -17
- package/tests/db-cache.test.ts +7 -7
- package/tests/field-encryption.test.ts +25 -25
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
- 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/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
- package/tests/index-spec.test.ts +5 -5
- package/tests/lifecycle.test.ts +96 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +239 -0
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +95 -0
- package/tests/vars.test.ts +216 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +64 -0
- package/types/file.d.ts +4 -1
- package/types/lifecycle.d.ts +71 -0
- package/types/replication.d.ts +199 -0
- package/types/tenant.d.ts +23 -0
- package/types/vars.d.ts +147 -0
- package/utils/func.ts +67 -52
package/lib/audit.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import type { Tenant } from "../types/tenant";
|
|
5
|
+
import { parseDuration } from "../utils/func";
|
|
6
|
+
import { resolveAuditRetention } from "../database/audit";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Local, append-only backup of the audit trail — one JSONL file per day,
|
|
10
|
+
* rotated on size and pruned by age. Meant for operators who want a copy of
|
|
11
|
+
* `_audit_` on the same machine as the logs, independent from MongoDB.
|
|
12
|
+
*/
|
|
13
|
+
export type AuditFileOptions = {
|
|
14
|
+
/** Target directory — default: `<server.logging.dir>/audit` (`.logs/audit`) */
|
|
15
|
+
dir?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Delete files older than this duration (`'30d'`).
|
|
18
|
+
* `false` → never prune. Omitted → mirrors `audit.retention` when set, else keeps files forever.
|
|
19
|
+
*/
|
|
20
|
+
retention?: string | false;
|
|
21
|
+
/** Rotate when the current file exceeds this size in bytes (default: 50MB) */
|
|
22
|
+
maxSize?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** `true` → defaults, `string` → directory shorthand, object → full options */
|
|
26
|
+
export type AuditFileConfig = boolean | string | AuditFileOptions;
|
|
27
|
+
|
|
28
|
+
type ResolvedAuditFile = {
|
|
29
|
+
dir: string;
|
|
30
|
+
retentionMs: number | null;
|
|
31
|
+
maxSize: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type Sink = ResolvedAuditFile & {
|
|
35
|
+
/** Current file ('' until opened) */
|
|
36
|
+
file: string;
|
|
37
|
+
/** `YYYY-MM-DD` of the current file */
|
|
38
|
+
day: string;
|
|
39
|
+
size: number;
|
|
40
|
+
pending: string[];
|
|
41
|
+
writing: boolean;
|
|
42
|
+
prunedAt: number;
|
|
43
|
+
inFlight: Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50MB
|
|
47
|
+
const PRUNE_INTERVAL_MS = 5 * 60 * 1000; // prune at most every 5 minutes
|
|
48
|
+
const EXT = '.jsonl';
|
|
49
|
+
|
|
50
|
+
const sinks = new Map<string, Sink>();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the file backup settings — **the tenant always wins**,
|
|
54
|
+
* `server.audit.file` is only the fallback. Returns `null` when disabled.
|
|
55
|
+
*/
|
|
56
|
+
function resolveAuditFile(tenant: Tenant): ResolvedAuditFile | null {
|
|
57
|
+
const raw: AuditFileConfig | undefined = tenant.audit?.file !== undefined
|
|
58
|
+
? tenant.audit.file
|
|
59
|
+
: cfg.server?.audit?.file
|
|
60
|
+
|
|
61
|
+
if (raw === undefined || raw === false) return null
|
|
62
|
+
|
|
63
|
+
const options: AuditFileOptions = raw === true
|
|
64
|
+
? {}
|
|
65
|
+
: typeof raw === 'string' ? { dir: raw } : raw
|
|
66
|
+
|
|
67
|
+
const dir = options.dir ?? path.join(cfg.server?.logging?.dir ?? '.logs', 'audit')
|
|
68
|
+
|
|
69
|
+
// Retention: explicit value wins (including `false`), otherwise the local
|
|
70
|
+
// copy mirrors the database retention so both follow the same policy.
|
|
71
|
+
const retention = options.retention !== undefined ? options.retention : resolveAuditRetention(tenant)
|
|
72
|
+
let retentionMs: number | null = null
|
|
73
|
+
if (retention !== undefined && retention !== false) {
|
|
74
|
+
const parsed = parseDuration(retention)
|
|
75
|
+
if (parsed === null) {
|
|
76
|
+
console.error(`Invalid audit file retention '${retention}' — expected a duration like '30d' or '24h'`)
|
|
77
|
+
} else {
|
|
78
|
+
retentionMs = parsed
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { dir, retentionMs, maxSize: options.maxSize ?? DEFAULT_MAX_SIZE }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function prefix(tenantId: string): string {
|
|
86
|
+
return `audit-${tenantId}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeRegex(value: string): string {
|
|
90
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `JSON.stringify` that never throws (an activity must never break a request) */
|
|
94
|
+
function safeStringify(value: unknown): string {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.stringify(value)
|
|
97
|
+
} catch {
|
|
98
|
+
return JSON.stringify({ error: 'unserializable activity' })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getSink(key: string, resolved: ResolvedAuditFile): Sink {
|
|
103
|
+
let sink = sinks.get(key)
|
|
104
|
+
if (!sink) {
|
|
105
|
+
sink = {
|
|
106
|
+
...resolved,
|
|
107
|
+
file: '',
|
|
108
|
+
day: '',
|
|
109
|
+
size: 0,
|
|
110
|
+
pending: [],
|
|
111
|
+
writing: false,
|
|
112
|
+
prunedAt: 0,
|
|
113
|
+
inFlight: Promise.resolve(),
|
|
114
|
+
}
|
|
115
|
+
sinks.set(key, sink)
|
|
116
|
+
} else {
|
|
117
|
+
// Options may change between boots / tests — always latest wins
|
|
118
|
+
sink.dir = resolved.dir
|
|
119
|
+
sink.retentionMs = resolved.retentionMs
|
|
120
|
+
sink.maxSize = resolved.maxSize
|
|
121
|
+
}
|
|
122
|
+
return sink
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pick the file `day` should be appended to: `audit-<tenant>-<day>.jsonl`,
|
|
127
|
+
* then `-1`, `-2`… when the size cap is reached.
|
|
128
|
+
*
|
|
129
|
+
* Files of the day are scanned in rotation order and the first one with room is
|
|
130
|
+
* reused — so a restart mid-day keeps appending to the current file, and the cap
|
|
131
|
+
* is never exceeded (a single line larger than the cap gets its own file).
|
|
132
|
+
*/
|
|
133
|
+
async function openFile(sink: Sink, tenantId: string, day: string, bytes: number): Promise<void> {
|
|
134
|
+
await fs.mkdir(sink.dir, { recursive: true })
|
|
135
|
+
|
|
136
|
+
const base = `${prefix(tenantId)}-${day}`
|
|
137
|
+
const re = new RegExp(`^${escapeRegex(base)}(?:-(\\d+))?${escapeRegex(EXT)}$`)
|
|
138
|
+
|
|
139
|
+
const candidates: { index: number; name: string; size: number }[] = []
|
|
140
|
+
try {
|
|
141
|
+
for (const entry of await fs.readdir(sink.dir)) {
|
|
142
|
+
const match = entry.match(re)
|
|
143
|
+
if (!match) continue
|
|
144
|
+
try {
|
|
145
|
+
candidates.push({
|
|
146
|
+
index: Number(match[1] ?? 0),
|
|
147
|
+
name: entry,
|
|
148
|
+
size: (await fs.stat(path.join(sink.dir, entry))).size,
|
|
149
|
+
})
|
|
150
|
+
} catch { /* vanished between readdir and stat */ }
|
|
151
|
+
}
|
|
152
|
+
} catch { /* unreadable dir — treated as empty */ }
|
|
153
|
+
|
|
154
|
+
candidates.sort((a, b) => a.index - b.index)
|
|
155
|
+
|
|
156
|
+
const target = candidates.find((candidate) => candidate.size + bytes <= sink.maxSize)
|
|
157
|
+
if (target) {
|
|
158
|
+
sink.file = path.join(sink.dir, target.name)
|
|
159
|
+
sink.day = day
|
|
160
|
+
sink.size = target.size
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const next = candidates.length ? candidates[candidates.length - 1]!.index + 1 : 0
|
|
165
|
+
sink.file = path.join(sink.dir, next === 0 ? `${base}${EXT}` : `${base}-${next}${EXT}`)
|
|
166
|
+
sink.day = day
|
|
167
|
+
sink.size = 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Delete this tenant's audit files older than `retentionMs` (by mtime). Returns the removed names. */
|
|
171
|
+
async function pruneAuditFiles(dir: string, tenantId: string, retentionMs: number): Promise<string[]> {
|
|
172
|
+
const removed: string[] = []
|
|
173
|
+
try {
|
|
174
|
+
const re = new RegExp(`^${escapeRegex(prefix(tenantId))}-.*${escapeRegex(EXT)}$`)
|
|
175
|
+
const cutoff = Date.now() - retentionMs
|
|
176
|
+
|
|
177
|
+
for (const entry of await fs.readdir(dir)) {
|
|
178
|
+
if (!re.test(entry)) continue
|
|
179
|
+
const full = path.join(dir, entry)
|
|
180
|
+
try {
|
|
181
|
+
const stat = await fs.stat(full)
|
|
182
|
+
if (stat.mtimeMs < cutoff) {
|
|
183
|
+
await fs.unlink(full)
|
|
184
|
+
removed.push(entry)
|
|
185
|
+
}
|
|
186
|
+
} catch { /* already gone */ }
|
|
187
|
+
}
|
|
188
|
+
} catch { /* dir missing — nothing to prune */ }
|
|
189
|
+
|
|
190
|
+
return removed
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function maybePrune(sink: Sink, tenantId: string): Promise<void> {
|
|
194
|
+
if (sink.retentionMs === null) return
|
|
195
|
+
if (Date.now() - sink.prunedAt < PRUNE_INTERVAL_MS) return
|
|
196
|
+
sink.prunedAt = Date.now()
|
|
197
|
+
const removed = await pruneAuditFiles(sink.dir, tenantId, sink.retentionMs)
|
|
198
|
+
if (removed.length) {
|
|
199
|
+
console.log(`🧹 Audit files pruned (${tenantId}): ${removed.length} file(s)`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Serialized per tenant: concurrent callers coalesce their lines into a single
|
|
205
|
+
* append, order is preserved, and lines are never interleaved.
|
|
206
|
+
*/
|
|
207
|
+
async function drain(key: string, tenantId: string): Promise<void> {
|
|
208
|
+
const sink = sinks.get(key)
|
|
209
|
+
if (!sink || sink.writing) return
|
|
210
|
+
sink.writing = true
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
while (sink.pending.length) {
|
|
214
|
+
const lines = sink.pending.splice(0)
|
|
215
|
+
const batch = lines.join('\n') + '\n'
|
|
216
|
+
const bytes = Buffer.byteLength(batch)
|
|
217
|
+
const day = new Date().toISOString().slice(0, 10)
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
if (!sink.file || sink.day !== day || sink.size + bytes > sink.maxSize) {
|
|
221
|
+
await openFile(sink, tenantId, day, bytes)
|
|
222
|
+
}
|
|
223
|
+
await fs.appendFile(sink.file, batch)
|
|
224
|
+
} catch (err) {
|
|
225
|
+
// Keep the lines for the next call instead of losing them
|
|
226
|
+
sink.pending.unshift(...lines)
|
|
227
|
+
throw err
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
sink.size += bytes
|
|
231
|
+
await maybePrune(sink, tenantId)
|
|
232
|
+
}
|
|
233
|
+
} catch (err: any) {
|
|
234
|
+
console.error(`Audit file write failed (${tenantId})`, err?.message)
|
|
235
|
+
} finally {
|
|
236
|
+
sink.writing = false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Append activities to the tenant's local audit file — opt-in, fire-and-forget:
|
|
242
|
+
* it never blocks the caller and never throws.
|
|
243
|
+
*/
|
|
244
|
+
function writeAuditFile(tenant: Tenant | null | undefined, activities: unknown[]): void {
|
|
245
|
+
if (!tenant || !activities?.length) return
|
|
246
|
+
|
|
247
|
+
const resolved = resolveAuditFile(tenant)
|
|
248
|
+
if (!resolved) return
|
|
249
|
+
|
|
250
|
+
const key = `${tenant.id}:${resolved.dir}`
|
|
251
|
+
const sink = getSink(key, resolved)
|
|
252
|
+
for (const activity of activities) sink.pending.push(safeStringify(activity))
|
|
253
|
+
|
|
254
|
+
sink.inFlight = drain(key, tenant.id).catch(() => {})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Wait until every pending line is on disk (tests, graceful shutdown). */
|
|
258
|
+
async function flushAuditFile(): Promise<void> {
|
|
259
|
+
for (let i = 0; i < 20; i++) {
|
|
260
|
+
const current = [...sinks.values()]
|
|
261
|
+
await Promise.all(current.map((sink) => sink.inFlight.catch(() => {})))
|
|
262
|
+
if (!current.some((sink) => sink.writing || sink.pending.length)) return
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export {
|
|
267
|
+
resolveAuditFile,
|
|
268
|
+
writeAuditFile,
|
|
269
|
+
flushAuditFile,
|
|
270
|
+
pruneAuditFiles
|
|
271
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
default:
|
|
111
|
+
// Anything unknown: a document must never be stored.
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export {
|
|
117
|
+
IDS_MAX,
|
|
118
|
+
auditResultsMode,
|
|
119
|
+
summarizeAuditResult,
|
|
120
|
+
}
|
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 {
|
|
@@ -167,5 +184,7 @@ export const define = {
|
|
|
167
184
|
Middleware,
|
|
168
185
|
WebSocket,
|
|
169
186
|
McpTool,
|
|
170
|
-
McpResource
|
|
187
|
+
McpResource,
|
|
188
|
+
Lifecycle,
|
|
189
|
+
Vars
|
|
171
190
|
}
|
package/lib/files.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
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";
|
|
@@ -13,6 +16,10 @@ import { ObjectId } from "mongodb";
|
|
|
13
16
|
|
|
14
17
|
export type FileResult = {
|
|
15
18
|
_id: string;
|
|
19
|
+
/** ISO string — set on upload */
|
|
20
|
+
createdAt: string;
|
|
21
|
+
/** ISO string — refreshed when the metadata is completed */
|
|
22
|
+
updatedAt: string;
|
|
16
23
|
_file: {
|
|
17
24
|
filename: string;
|
|
18
25
|
name: string;
|
|
@@ -54,7 +61,7 @@ export function createDiskStorage(baseDir?: string): FileStorage {
|
|
|
54
61
|
const filename = `${meta.id}${ext}`;
|
|
55
62
|
const filepath = path.join(dir, filename);
|
|
56
63
|
const buffer = await file.arrayBuffer();
|
|
57
|
-
//
|
|
64
|
+
// Flaw 17: 'wx' — fail if file already exists (no silent overwrite)
|
|
58
65
|
await fs.writeFile(filepath, new Uint8Array(buffer), { flag: 'wx' });
|
|
59
66
|
const outPath = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
|
|
60
67
|
return { path: outPath, size: buffer.byteLength };
|
|
@@ -135,7 +142,7 @@ async function transformImage(
|
|
|
135
142
|
const format = options.format || 'webp';
|
|
136
143
|
const quality = options.quality ?? 80;
|
|
137
144
|
|
|
138
|
-
//
|
|
145
|
+
// Flaw 20: validate params (reject NaN / out-of-range)
|
|
139
146
|
if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
|
|
140
147
|
throw new AppError('Invalid quality parameter', { code: 'INVALID_TRANSFORM', status: 400 });
|
|
141
148
|
}
|
|
@@ -144,7 +151,7 @@ async function transformImage(
|
|
|
144
151
|
throw new AppError('Invalid width/height parameter', { code: 'INVALID_TRANSFORM', status: 400 });
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
//
|
|
154
|
+
// Convert a ReadableStream to a Uint8Array if needed
|
|
148
155
|
let buffer: Uint8Array;
|
|
149
156
|
if (input instanceof Uint8Array) {
|
|
150
157
|
buffer = input;
|
|
@@ -165,13 +172,13 @@ async function transformImage(
|
|
|
165
172
|
}
|
|
166
173
|
}
|
|
167
174
|
|
|
168
|
-
//
|
|
175
|
+
// Flaw 20: only transform real images (magic bytes check)
|
|
169
176
|
const detected = detectMimeFromBuffer(buffer);
|
|
170
177
|
if (!detected || !detected.startsWith('image/')) {
|
|
171
178
|
throw new AppError('Not an image file', { code: 'NOT_AN_IMAGE', status: 400 });
|
|
172
179
|
}
|
|
173
180
|
|
|
174
|
-
//
|
|
181
|
+
// Use Bun.Image (native, zero dependencies)
|
|
175
182
|
const img = new Bun.Image(buffer);
|
|
176
183
|
|
|
177
184
|
if (options.width || options.height) {
|
|
@@ -181,7 +188,7 @@ async function transformImage(
|
|
|
181
188
|
});
|
|
182
189
|
}
|
|
183
190
|
|
|
184
|
-
//
|
|
191
|
+
// Encode in the requested format
|
|
185
192
|
let out: Blob;
|
|
186
193
|
switch (format) {
|
|
187
194
|
case 'jpeg':
|
|
@@ -236,6 +243,7 @@ export function getStorageForCollection(slug: string, tenant_id: string): FileSt
|
|
|
236
243
|
* Handle a file upload for a given collection.
|
|
237
244
|
*/
|
|
238
245
|
export async function handleUpload(options: UploadOptions): Promise<FileResult> {
|
|
246
|
+
const startedAt = Date.now();
|
|
239
247
|
const { collection, tenant_id, file, data } = options;
|
|
240
248
|
|
|
241
249
|
const col = getFileCollection(collection, tenant_id);
|
|
@@ -253,7 +261,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
253
261
|
});
|
|
254
262
|
}
|
|
255
263
|
|
|
256
|
-
//
|
|
264
|
+
// Flaw 4: validate magic bytes — don't trust client-declared Content-Type
|
|
257
265
|
const fullBuffer = new Uint8Array(await file.arrayBuffer());
|
|
258
266
|
const probe = fullBuffer.subarray(0, 4096);
|
|
259
267
|
const detected = detectMimeFromBuffer(probe);
|
|
@@ -283,6 +291,9 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
283
291
|
});
|
|
284
292
|
}
|
|
285
293
|
|
|
294
|
+
// Timestamps (ISO strings) — stored and returned in the response
|
|
295
|
+
const now = new Date()
|
|
296
|
+
|
|
286
297
|
// Insert metadata first to get the MongoDB auto-generated ObjectId
|
|
287
298
|
let _id: string;
|
|
288
299
|
try {
|
|
@@ -290,6 +301,8 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
290
301
|
// Form-data / JSON always sends IDs as strings, but MongoDB expects
|
|
291
302
|
// ObjectId for fields that reference other collections.
|
|
292
303
|
const insertDoc: Record<string, any> = {
|
|
304
|
+
createdAt: now,
|
|
305
|
+
updatedAt: now,
|
|
293
306
|
_file: {
|
|
294
307
|
filename: '',
|
|
295
308
|
name: file.name,
|
|
@@ -325,7 +338,13 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
325
338
|
}
|
|
326
339
|
}
|
|
327
340
|
const rest = new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false });
|
|
328
|
-
|
|
341
|
+
// Convert the custom fields exactly like a regular collection insert
|
|
342
|
+
// (ISO date strings → Date, hex ids → ObjectId, …) so file metadata matches
|
|
343
|
+
// the collections' storage types. `_file` is framework-generated and must
|
|
344
|
+
// stay untouched (a filename could otherwise look like a date or an ObjectId).
|
|
345
|
+
const { _file, ...fields } = insertDoc;
|
|
346
|
+
const stored = { _file, ...toBson<Record<string, any>>(fields, { col } as any) };
|
|
347
|
+
const result = await rest.db.collection(collection).insertOne(stored as any);
|
|
329
348
|
_id = String(result.insertedId);
|
|
330
349
|
} catch (err) {
|
|
331
350
|
throw new AppError('Failed to save file metadata', {
|
|
@@ -346,7 +365,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
346
365
|
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
347
366
|
await rest.db.collection(collection).updateOne(
|
|
348
367
|
{ _id: new ObjectId(_id) },
|
|
349
|
-
{ $set: { '_file.filename': filename, '_file.size': size, '_file.url': url } },
|
|
368
|
+
{ $set: { '_file.filename': filename, '_file.size': size, '_file.url': url, updatedAt: new Date() } },
|
|
350
369
|
);
|
|
351
370
|
} catch (err) {
|
|
352
371
|
await storage.delete(tenant_id, collection, _id, filename, subpath).catch(() => {});
|
|
@@ -362,8 +381,26 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
362
381
|
});
|
|
363
382
|
}
|
|
364
383
|
|
|
384
|
+
// Audit the upload — the file metadata and the generated id, never the stored
|
|
385
|
+
// document (see the Audit page: parameters only). `internal` is taken from the
|
|
386
|
+
// request context, so an HTTP upload is logged as such.
|
|
387
|
+
try {
|
|
388
|
+
await new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false }).audit.log({
|
|
389
|
+
action: 'upload',
|
|
390
|
+
collection,
|
|
391
|
+
input: {
|
|
392
|
+
file: { name: file.name, mimetype, size },
|
|
393
|
+
fields: Object.keys(data ?? {}),
|
|
394
|
+
},
|
|
395
|
+
result: { _id },
|
|
396
|
+
duration: Date.now() - startedAt,
|
|
397
|
+
});
|
|
398
|
+
} catch { /* auditing must never fail an upload */ }
|
|
399
|
+
|
|
365
400
|
return {
|
|
366
401
|
_id,
|
|
402
|
+
createdAt: now.toISOString(),
|
|
403
|
+
updatedAt: now.toISOString(),
|
|
367
404
|
_file: {
|
|
368
405
|
filename,
|
|
369
406
|
name: file.name,
|
|
@@ -391,7 +428,7 @@ export async function handleServe(
|
|
|
391
428
|
const stream = await storage.getStream(tenant_id, collection, fileId, filename, subpath);
|
|
392
429
|
if (!stream) return null;
|
|
393
430
|
|
|
394
|
-
//
|
|
431
|
+
// Flaw 10: serve the MIME validated at upload time (content-checked),
|
|
395
432
|
// not re-derived from the extension.
|
|
396
433
|
let mimetype: string | undefined;
|
|
397
434
|
try {
|
|
@@ -412,7 +449,7 @@ export async function handleServe(
|
|
|
412
449
|
return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
|
|
413
450
|
}
|
|
414
451
|
|
|
415
|
-
//
|
|
452
|
+
// Flaw 4: SVG is served as an attachment (never inline) to prevent stored XSS
|
|
416
453
|
if (isSvg) {
|
|
417
454
|
return { stream, mimetype, attachment: true };
|
|
418
455
|
}
|
|
@@ -477,6 +514,7 @@ export async function handleDelete(
|
|
|
477
514
|
collection: string,
|
|
478
515
|
fileId: string,
|
|
479
516
|
): Promise<void> {
|
|
517
|
+
const startedAt = Date.now();
|
|
480
518
|
const storage = getStorageForCollection(collection, tenant_id);
|
|
481
519
|
const col = getFileCollection(collection, tenant_id);
|
|
482
520
|
const subpath = col?.storage?.path || undefined;
|
|
@@ -511,6 +549,21 @@ export async function handleDelete(
|
|
|
511
549
|
await rest.db.collection(collection).deleteOne({
|
|
512
550
|
_id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any,
|
|
513
551
|
});
|
|
552
|
+
// This path bypasses the collection hooks — record a tombstone explicitly so the
|
|
553
|
+
// deletion propagates when the file collection opted into replication.
|
|
554
|
+
if (col?.replication?.enabled) {
|
|
555
|
+
const db = getTenant(tenant_id)?.database?.db;
|
|
556
|
+
if (db) await writeDeleteMarkers(db, tenant_id, collection, [fileId]);
|
|
557
|
+
}
|
|
558
|
+
// Audited like any other delete: the id and the stored filename, nothing else.
|
|
559
|
+
// (`internal` is taken from the request context, so an HTTP call is logged as such.)
|
|
560
|
+
await rest.audit.log({
|
|
561
|
+
action: 'deleteFile',
|
|
562
|
+
collection,
|
|
563
|
+
input: { id: fileId, filename },
|
|
564
|
+
result: { deleted: true },
|
|
565
|
+
duration: Date.now() - startedAt,
|
|
566
|
+
});
|
|
514
567
|
} catch (err: any) {
|
|
515
568
|
console.error('Failed to delete file metadata:', err?.message || err);
|
|
516
569
|
}
|