@anteros/core 0.0.40 → 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 +3 -3
- package/database/deleteLog.ts +34 -0
- 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 +109 -25
- 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/db-cache.test.ts +7 -7
- package/tests/file-collection-replication.test.ts +98 -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/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
- 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 +102 -1
- 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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { Db } from "mongodb";
|
|
2
|
+
import { cfg } from "../server/config";
|
|
3
|
+
import type { Tenant } from "../types/tenant";
|
|
4
|
+
import { parseDuration } from "../utils/func";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Collection holding the audit trail — one document per database operation
|
|
8
|
+
* (see `types/activity.d.ts` / `rest.audit`).
|
|
9
|
+
*/
|
|
10
|
+
const AUDIT_COLLECTION = '_audit_'
|
|
11
|
+
|
|
12
|
+
/** Previous name of the audit collection — renamed once at boot by `migrateAuditCollection`. */
|
|
13
|
+
const LEGACY_AUDIT_COLLECTION = '_activities_'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TTL index used for retention. Created only when a `tenant.audit.retention`
|
|
17
|
+
* is configured — audit entries are kept forever by default.
|
|
18
|
+
*/
|
|
19
|
+
const AUDIT_TTL_INDEX = '_audit_ttl_'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Query indexes, always ensured in the background (same convention as `_vars_`),
|
|
23
|
+
* whatever the tenant config — an audit collection is queried by date, by
|
|
24
|
+
* collection, by status and by trace, and grows fast enough that a COLLSCAN
|
|
25
|
+
* is never acceptable.
|
|
26
|
+
*/
|
|
27
|
+
const AUDIT_INDEXES: { name: string; key: Record<string, 1 | -1> }[] = [
|
|
28
|
+
/** Listing / date ranges (`getActivities` sorts on `ts` by default) */
|
|
29
|
+
{ name: '_audit_ts_', key: { ts: -1 } },
|
|
30
|
+
/** `$match: { 'operation.collection': … }` + recent first */
|
|
31
|
+
{ name: '_audit_collection_ts_', key: { 'operation.collection': 1, ts: -1 } },
|
|
32
|
+
/** `$match: { 'operation.status': 'error' }` + recent first */
|
|
33
|
+
{ name: '_audit_status_ts_', key: { 'operation.status': 1, ts: -1 } },
|
|
34
|
+
/** Correlate every operation of a request (`trace.id`) */
|
|
35
|
+
{ name: '_audit_trace_', key: { 'trace.id': 1 } },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
type RetentionResult = 'applied' | 'dropped' | 'unchanged' | 'skipped'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One-off migration: `_activities_` → `_audit_`.
|
|
42
|
+
*
|
|
43
|
+
* Idempotent and never fatal:
|
|
44
|
+
* - legacy collection absent → no-op (the common case after the migration)
|
|
45
|
+
* - target already exists → no-op, the legacy collection is left untouched
|
|
46
|
+
* (never merged nor dropped: losing audit entries silently is worse than a stale collection)
|
|
47
|
+
*/
|
|
48
|
+
async function migrateAuditCollection(db: Db): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
const legacy = await db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray()
|
|
51
|
+
if (legacy.length === 0) return false
|
|
52
|
+
|
|
53
|
+
const target = await db.listCollections({ name: AUDIT_COLLECTION }).toArray()
|
|
54
|
+
if (target.length > 0) return false
|
|
55
|
+
|
|
56
|
+
await db.renameCollection(LEGACY_AUDIT_COLLECTION, AUDIT_COLLECTION, { dropTarget: false })
|
|
57
|
+
console.log(`🔁 Audit collection renamed: ${LEGACY_AUDIT_COLLECTION} → ${AUDIT_COLLECTION}`)
|
|
58
|
+
return true
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
console.error(
|
|
61
|
+
`Audit collection migration failed (${LEGACY_AUDIT_COLLECTION} → ${AUDIT_COLLECTION})`,
|
|
62
|
+
err?.message,
|
|
63
|
+
)
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ensure the audit query indexes. `createIndex` is idempotent, so this runs on
|
|
70
|
+
* every boot and only fills in what is missing. Never fatal, never blocking
|
|
71
|
+
* (`background: true`).
|
|
72
|
+
*
|
|
73
|
+
* ⚠️ Indexes cost writes, and the audit collection is the most write-heavy one —
|
|
74
|
+
* four indexes are a deliberate trade-off (see the Audit documentation).
|
|
75
|
+
*/
|
|
76
|
+
async function ensureAuditIndexes(db: Db): Promise<string[]> {
|
|
77
|
+
const ensured: string[] = []
|
|
78
|
+
const col: any = db.collection(AUDIT_COLLECTION)
|
|
79
|
+
|
|
80
|
+
for (const index of AUDIT_INDEXES) {
|
|
81
|
+
try {
|
|
82
|
+
await col.createIndex(index.key, { name: index.name, background: true })
|
|
83
|
+
ensured.push(index.name)
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
console.error(`Audit index '${index.name}' failed`, err?.message)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return ensured
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Declarative retention (MongoDB TTL on `ts`):
|
|
94
|
+
* - duration string (`'90d'`, `'24h'`) → ensure the TTL index with that expiry
|
|
95
|
+
* - `false` → explicitly disabled: drop the TTL index
|
|
96
|
+
* - omitted → untouched (entries are kept forever)
|
|
97
|
+
*/
|
|
98
|
+
async function syncAuditRetention(db: Db, retention?: string | false): Promise<RetentionResult> {
|
|
99
|
+
if (retention === undefined) return 'skipped'
|
|
100
|
+
|
|
101
|
+
const col: any = db.collection(AUDIT_COLLECTION)
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const existing = (await col.listIndexes().toArray())
|
|
105
|
+
.find((index: any) => index.name === AUDIT_TTL_INDEX)
|
|
106
|
+
|
|
107
|
+
if (retention === false) {
|
|
108
|
+
if (!existing) return 'unchanged'
|
|
109
|
+
await col.dropIndex(AUDIT_TTL_INDEX)
|
|
110
|
+
console.log(`🧹 Audit retention disabled — ${AUDIT_TTL_INDEX} dropped`)
|
|
111
|
+
return 'dropped'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parsed = parseDuration(retention)
|
|
115
|
+
if (parsed === null) {
|
|
116
|
+
console.error(`Invalid audit retention '${retention}' — expected a duration like '90d' or '24h'`)
|
|
117
|
+
return 'skipped'
|
|
118
|
+
}
|
|
119
|
+
const expireAfterSeconds = Math.round(parsed / 1000)
|
|
120
|
+
|
|
121
|
+
if (!existing) {
|
|
122
|
+
await col.createIndex({ ts: 1 }, {
|
|
123
|
+
name: AUDIT_TTL_INDEX,
|
|
124
|
+
expireAfterSeconds,
|
|
125
|
+
background: true,
|
|
126
|
+
})
|
|
127
|
+
console.log(`🧹 Audit retention enabled — entries expire after ${retention}`)
|
|
128
|
+
return 'applied'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (existing.expireAfterSeconds === expireAfterSeconds) return 'unchanged'
|
|
132
|
+
|
|
133
|
+
// `expireAfterSeconds` cannot be updated through createIndex: collMod does it
|
|
134
|
+
// instantly, dropping/recreating the index is only a fallback for older servers
|
|
135
|
+
try {
|
|
136
|
+
await db.command({
|
|
137
|
+
collMod: AUDIT_COLLECTION,
|
|
138
|
+
index: { name: AUDIT_TTL_INDEX, expireAfterSeconds },
|
|
139
|
+
})
|
|
140
|
+
} catch {
|
|
141
|
+
await col.dropIndex(AUDIT_TTL_INDEX)
|
|
142
|
+
await col.createIndex({ ts: 1 }, {
|
|
143
|
+
name: AUDIT_TTL_INDEX,
|
|
144
|
+
expireAfterSeconds,
|
|
145
|
+
background: true,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
console.log(`🧹 Audit retention updated — entries expire after ${retention}`)
|
|
149
|
+
return 'applied'
|
|
150
|
+
} catch (err: any) {
|
|
151
|
+
console.error(`Audit retention failed (${retention})`, err?.message)
|
|
152
|
+
return 'skipped'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Retention resolution — **the tenant always wins**, `server.audit.retention`
|
|
158
|
+
* is only the fallback:
|
|
159
|
+
*
|
|
160
|
+
* - `tenant.audit.retention` set (a duration, even `false`) → used as-is
|
|
161
|
+
* - otherwise → `server.audit.retention`
|
|
162
|
+
* - neither → `undefined` (kept forever)
|
|
163
|
+
*/
|
|
164
|
+
function resolveAuditRetention(tenant: Tenant): string | false | undefined {
|
|
165
|
+
if (tenant.audit?.retention !== undefined) return tenant.audit.retention
|
|
166
|
+
return cfg.server?.audit?.retention
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Boot setup for a tenant's audit collection, in this order:
|
|
171
|
+
* migration → indexes → retention.
|
|
172
|
+
*
|
|
173
|
+
* The migration comes first on purpose: creating the indexes would create an
|
|
174
|
+
* empty `_audit_`, and the migration would then refuse to rename the legacy
|
|
175
|
+
* collection (leaving its entries stranded).
|
|
176
|
+
*/
|
|
177
|
+
async function setupAuditCollection(db: Db, retention?: string | false): Promise<void> {
|
|
178
|
+
await migrateAuditCollection(db)
|
|
179
|
+
await ensureAuditIndexes(db)
|
|
180
|
+
await syncAuditRetention(db, retention)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export {
|
|
184
|
+
AUDIT_COLLECTION,
|
|
185
|
+
LEGACY_AUDIT_COLLECTION,
|
|
186
|
+
AUDIT_TTL_INDEX,
|
|
187
|
+
AUDIT_INDEXES,
|
|
188
|
+
migrateAuditCollection,
|
|
189
|
+
ensureAuditIndexes,
|
|
190
|
+
syncAuditRetention,
|
|
191
|
+
resolveAuditRetention,
|
|
192
|
+
setupAuditCollection
|
|
193
|
+
}
|
package/database/collection.ts
CHANGED
|
@@ -49,7 +49,7 @@ async function syncCollections() {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
//
|
|
52
|
+
// build the arktype schemas for the collections
|
|
53
53
|
for (let collection of collections) {
|
|
54
54
|
collection._schema_ = buildSchema(collection)
|
|
55
55
|
collection._schemaPartial_ = buildSchema(collection, { partial: true })
|
|
@@ -102,7 +102,7 @@ async function initializeOnDatabase(collections: Collection[]) {
|
|
|
102
102
|
|
|
103
103
|
|
|
104
104
|
|
|
105
|
-
//
|
|
105
|
+
// create index on the fields
|
|
106
106
|
let specsFieldsIndexes: IndexDescription[] = []
|
|
107
107
|
for (let field of collection?.fields || []) {
|
|
108
108
|
field.indexOptions = field.indexOptions ?? {}
|
|
@@ -190,7 +190,7 @@ function getCollection(collectionName: string, tenantId: string): Collection | n
|
|
|
190
190
|
return col
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
//
|
|
193
|
+
// Also look in the file collections
|
|
194
194
|
let fileCol = cfg.fileCollections?.find(fc => fc.slug == collectionName && fc._tenant_ == tenantId)
|
|
195
195
|
if (fileCol) {
|
|
196
196
|
return {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Db } from "mongodb";
|
|
2
|
+
|
|
3
|
+
/** Collection holding the replication state (`_replication_`). */
|
|
4
|
+
const MARKERS = "_replication_";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Upsert delete tombstones consumed by the replication engine.
|
|
8
|
+
*
|
|
9
|
+
* Some collections bypass the collection hooks when deleting (variables,
|
|
10
|
+
* file-collection documents) — those paths call this directly so their
|
|
11
|
+
* deletions still propagate to the destinations.
|
|
12
|
+
*/
|
|
13
|
+
async function writeDeleteMarkers(
|
|
14
|
+
db: Db,
|
|
15
|
+
tenantId: string,
|
|
16
|
+
collection: string,
|
|
17
|
+
docIds: string[],
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
if (!docIds.length) return;
|
|
20
|
+
const now = new Date();
|
|
21
|
+
const ops = docIds.map((docId) => ({
|
|
22
|
+
updateOne: {
|
|
23
|
+
filter: { _id: `rep.delete:${tenantId}:${collection}:${docId}` },
|
|
24
|
+
update: {
|
|
25
|
+
$set: { type: "delete", tenant: tenantId, collection, docId, deletedAt: now, updatedAt: now },
|
|
26
|
+
$setOnInsert: { synced: [] },
|
|
27
|
+
},
|
|
28
|
+
upsert: true,
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
await db.collection(MARKERS).bulkWrite(ops as any, { ordered: false }).catch(() => { /* ignore */ });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { writeDeleteMarkers };
|
package/database/file.ts
CHANGED
|
@@ -75,14 +75,14 @@ async function initializeFileCollectionsOnDatabase(files: FileCollection[]) {
|
|
|
75
75
|
const db = tenant?.database?.db
|
|
76
76
|
if (!db) continue;
|
|
77
77
|
|
|
78
|
-
//
|
|
78
|
+
// Create the collection if it does not exist
|
|
79
79
|
const collections = await db.listCollections({ name: fileCollection.slug }).toArray()
|
|
80
80
|
if (collections.length === 0) {
|
|
81
81
|
await db.createCollection(fileCollection.slug)
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
84
|
+
// Create the default indexes (only the missing ones —
|
|
85
|
+
// never force the re-creation of an already existing index)
|
|
86
86
|
const col = db.collection(fileCollection.slug)
|
|
87
87
|
const existingIndexes = await col.listIndexes().toArray()
|
|
88
88
|
const existingNames = new Set(existingIndexes.map((idx) => idx.name))
|