@agentlensai/server 0.8.0 → 0.10.0
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/dist/db/anonymous-id-manager.d.ts +44 -0
- package/dist/db/anonymous-id-manager.d.ts.map +1 -0
- package/dist/db/anonymous-id-manager.js +90 -0
- package/dist/db/anonymous-id-manager.js.map +1 -0
- package/dist/db/capability-store.d.ts +82 -0
- package/dist/db/capability-store.d.ts.map +1 -0
- package/dist/db/capability-store.js +221 -0
- package/dist/db/capability-store.js.map +1 -0
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +136 -0
- package/dist/db/migrate.js.map +1 -1
- package/dist/db/schema.sqlite.d.ts +1663 -2
- package/dist/db/schema.sqlite.d.ts.map +1 -1
- package/dist/db/schema.sqlite.js +135 -1
- package/dist/db/schema.sqlite.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/redaction/human-review-layer.d.ts +37 -0
- package/dist/lib/redaction/human-review-layer.d.ts.map +1 -0
- package/dist/lib/redaction/human-review-layer.js +62 -0
- package/dist/lib/redaction/human-review-layer.js.map +1 -0
- package/dist/lib/redaction/index.d.ts +12 -0
- package/dist/lib/redaction/index.d.ts.map +1 -0
- package/dist/lib/redaction/index.js +12 -0
- package/dist/lib/redaction/index.js.map +1 -0
- package/dist/lib/redaction/pii-detection-layer.d.ts +30 -0
- package/dist/lib/redaction/pii-detection-layer.d.ts.map +1 -0
- package/dist/lib/redaction/pii-detection-layer.js +183 -0
- package/dist/lib/redaction/pii-detection-layer.js.map +1 -0
- package/dist/lib/redaction/pipeline.d.ts +26 -0
- package/dist/lib/redaction/pipeline.d.ts.map +1 -0
- package/dist/lib/redaction/pipeline.js +91 -0
- package/dist/lib/redaction/pipeline.js.map +1 -0
- package/dist/lib/redaction/secret-detection-layer.d.ts +10 -0
- package/dist/lib/redaction/secret-detection-layer.d.ts.map +1 -0
- package/dist/lib/redaction/secret-detection-layer.js +79 -0
- package/dist/lib/redaction/secret-detection-layer.js.map +1 -0
- package/dist/lib/redaction/secret-patterns.d.ts +29 -0
- package/dist/lib/redaction/secret-patterns.d.ts.map +1 -0
- package/dist/lib/redaction/secret-patterns.js +133 -0
- package/dist/lib/redaction/secret-patterns.js.map +1 -0
- package/dist/lib/redaction/semantic-denylist-layer.d.ts +10 -0
- package/dist/lib/redaction/semantic-denylist-layer.d.ts.map +1 -0
- package/dist/lib/redaction/semantic-denylist-layer.js +64 -0
- package/dist/lib/redaction/semantic-denylist-layer.js.map +1 -0
- package/dist/lib/redaction/tenant-deidentification-layer.d.ts +10 -0
- package/dist/lib/redaction/tenant-deidentification-layer.d.ts.map +1 -0
- package/dist/lib/redaction/tenant-deidentification-layer.js +64 -0
- package/dist/lib/redaction/tenant-deidentification-layer.js.map +1 -0
- package/dist/lib/redaction/url-path-scrubbing-layer.d.ts +14 -0
- package/dist/lib/redaction/url-path-scrubbing-layer.d.ts.map +1 -0
- package/dist/lib/redaction/url-path-scrubbing-layer.js +156 -0
- package/dist/lib/redaction/url-path-scrubbing-layer.js.map +1 -0
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +3 -9
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/audit.d.ts +15 -0
- package/dist/routes/audit.d.ts.map +1 -0
- package/dist/routes/audit.js +177 -0
- package/dist/routes/audit.js.map +1 -0
- package/dist/routes/capabilities-top.d.ts +15 -0
- package/dist/routes/capabilities-top.d.ts.map +1 -0
- package/dist/routes/capabilities-top.js +77 -0
- package/dist/routes/capabilities-top.js.map +1 -0
- package/dist/routes/capabilities.d.ts +15 -0
- package/dist/routes/capabilities.d.ts.map +1 -0
- package/dist/routes/capabilities.js +86 -0
- package/dist/routes/capabilities.js.map +1 -0
- package/dist/routes/community.d.ts +24 -0
- package/dist/routes/community.d.ts.map +1 -0
- package/dist/routes/community.js +272 -0
- package/dist/routes/community.js.map +1 -0
- package/dist/routes/delegation.d.ts +20 -0
- package/dist/routes/delegation.d.ts.map +1 -0
- package/dist/routes/delegation.js +108 -0
- package/dist/routes/delegation.js.map +1 -0
- package/dist/routes/delegations-top.d.ts +12 -0
- package/dist/routes/delegations-top.d.ts.map +1 -0
- package/dist/routes/delegations-top.js +43 -0
- package/dist/routes/delegations-top.js.map +1 -0
- package/dist/routes/discovery.d.ts +19 -0
- package/dist/routes/discovery.d.ts.map +1 -0
- package/dist/routes/discovery.js +96 -0
- package/dist/routes/discovery.js.map +1 -0
- package/dist/routes/redaction-test.d.ts +14 -0
- package/dist/routes/redaction-test.d.ts.map +1 -0
- package/dist/routes/redaction-test.js +33 -0
- package/dist/routes/redaction-test.js.map +1 -0
- package/dist/routes/trust.d.ts +16 -0
- package/dist/routes/trust.d.ts.map +1 -0
- package/dist/routes/trust.js +23 -0
- package/dist/routes/trust.js.map +1 -0
- package/dist/services/community-service.d.ts +283 -0
- package/dist/services/community-service.d.ts.map +1 -0
- package/dist/services/community-service.js +816 -0
- package/dist/services/community-service.js.map +1 -0
- package/dist/services/delegation-service.d.ts +149 -0
- package/dist/services/delegation-service.d.ts.map +1 -0
- package/dist/services/delegation-service.js +605 -0
- package/dist/services/delegation-service.js.map +1 -0
- package/dist/services/discovery-service.d.ts +39 -0
- package/dist/services/discovery-service.d.ts.map +1 -0
- package/dist/services/discovery-service.js +186 -0
- package/dist/services/discovery-service.js.map +1 -0
- package/dist/services/trust-service.d.ts +59 -0
- package/dist/services/trust-service.d.ts.map +1 -0
- package/dist/services/trust-service.js +139 -0
- package/dist/services/trust-service.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Community Sharing Service (Stories 4.1–4.3)
|
|
3
|
+
*
|
|
4
|
+
* Handles lesson sharing, search, configuration, and deny-list management.
|
|
5
|
+
* Enforces hierarchical toggles: tenant OFF overrides agent ON.
|
|
6
|
+
* Rate limiting: 50/hr per tenant. Audit log on every operation.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
9
|
+
import { eq, and } from 'drizzle-orm';
|
|
10
|
+
import * as schema from '../db/schema.sqlite.js';
|
|
11
|
+
import { AnonymousIdManager } from '../db/anonymous-id-manager.js';
|
|
12
|
+
import { RedactionPipeline } from '../lib/redaction/pipeline.js';
|
|
13
|
+
import { LessonStore } from '../db/lesson-store.js';
|
|
14
|
+
import { LESSON_SHARING_CATEGORIES, createRawLessonContent } from '@agentlensai/core';
|
|
15
|
+
/** In-memory transport for testing */
|
|
16
|
+
export class LocalCommunityPoolTransport {
|
|
17
|
+
shared = [];
|
|
18
|
+
purgeTokens = new Map();
|
|
19
|
+
reputationEvents = [];
|
|
20
|
+
flags = [];
|
|
21
|
+
async share(data) {
|
|
22
|
+
const id = randomUUID();
|
|
23
|
+
this.shared.push({ id, ...data, reputationScore: 50, flagCount: 0, hidden: false });
|
|
24
|
+
return { id };
|
|
25
|
+
}
|
|
26
|
+
async search(data) {
|
|
27
|
+
let results = this.shared
|
|
28
|
+
.filter((l) => !l.hidden)
|
|
29
|
+
.map((lesson) => ({
|
|
30
|
+
lesson: {
|
|
31
|
+
id: lesson.id,
|
|
32
|
+
category: lesson.category,
|
|
33
|
+
title: lesson.title,
|
|
34
|
+
content: lesson.content,
|
|
35
|
+
reputationScore: lesson.reputationScore,
|
|
36
|
+
qualitySignals: lesson.qualitySignals ?? {},
|
|
37
|
+
},
|
|
38
|
+
similarity: cosineSimilarity(data.embedding, lesson.embedding),
|
|
39
|
+
}));
|
|
40
|
+
if (data.category) {
|
|
41
|
+
results = results.filter((r) => r.lesson.category === data.category);
|
|
42
|
+
}
|
|
43
|
+
if (data.minReputation !== undefined) {
|
|
44
|
+
results = results.filter((r) => r.lesson.reputationScore >= data.minReputation);
|
|
45
|
+
}
|
|
46
|
+
results.sort((a, b) => b.similarity - a.similarity);
|
|
47
|
+
return { results: results.slice(0, data.limit ?? 50) };
|
|
48
|
+
}
|
|
49
|
+
async registerPurgeToken(data) {
|
|
50
|
+
this.purgeTokens.set(data.anonymousContributorId, data.token);
|
|
51
|
+
}
|
|
52
|
+
async purge(data) {
|
|
53
|
+
// C2 FIX: Validate purge token before deleting
|
|
54
|
+
const storedToken = this.purgeTokens.get(data.anonymousContributorId);
|
|
55
|
+
if (!storedToken || storedToken !== data.token) {
|
|
56
|
+
throw new Error('Invalid purge token');
|
|
57
|
+
}
|
|
58
|
+
let deleted = 0;
|
|
59
|
+
for (let i = this.shared.length - 1; i >= 0; i--) {
|
|
60
|
+
if (this.shared[i].anonymousContributorId === data.anonymousContributorId) {
|
|
61
|
+
this.shared.splice(i, 1);
|
|
62
|
+
deleted++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.purgeTokens.delete(data.anonymousContributorId);
|
|
66
|
+
return { deleted };
|
|
67
|
+
}
|
|
68
|
+
async count(contributorId) {
|
|
69
|
+
return { count: this.shared.filter((l) => l.anonymousContributorId === contributorId).length };
|
|
70
|
+
}
|
|
71
|
+
async rate(data) {
|
|
72
|
+
// Check daily cap
|
|
73
|
+
const todayStart = Math.floor(Date.now() / 86400000) * 86400;
|
|
74
|
+
const todayVotes = this.reputationEvents.filter((e) => e.voterAnonymousId === data.voterAnonymousId && e.createdEpoch >= todayStart);
|
|
75
|
+
if (todayVotes.length >= 5) {
|
|
76
|
+
throw new Error('Daily rating cap exceeded');
|
|
77
|
+
}
|
|
78
|
+
this.reputationEvents.push({ ...data, createdEpoch: Math.floor(Date.now() / 1000) });
|
|
79
|
+
const lesson = this.shared.find((l) => l.id === data.lessonId);
|
|
80
|
+
if (!lesson)
|
|
81
|
+
throw new Error('Lesson not found');
|
|
82
|
+
const lessonEvents = this.reputationEvents.filter((e) => e.lessonId === data.lessonId);
|
|
83
|
+
const totalDelta = lessonEvents.reduce((sum, e) => sum + e.delta, 0);
|
|
84
|
+
lesson.reputationScore = 50 + totalDelta;
|
|
85
|
+
// Auto-hide below threshold
|
|
86
|
+
if (lesson.reputationScore < 20) {
|
|
87
|
+
lesson.hidden = true;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
lesson.hidden = false;
|
|
91
|
+
}
|
|
92
|
+
return { reputationScore: lesson.reputationScore };
|
|
93
|
+
}
|
|
94
|
+
async flag(data) {
|
|
95
|
+
// Check duplicate
|
|
96
|
+
if (this.flags.some((f) => f.lessonId === data.lessonId && f.reporterAnonymousId === data.reporterAnonymousId)) {
|
|
97
|
+
throw new Error('Already flagged');
|
|
98
|
+
}
|
|
99
|
+
this.flags.push({ ...data, createdEpoch: Math.floor(Date.now() / 1000) });
|
|
100
|
+
const lesson = this.shared.find((l) => l.id === data.lessonId);
|
|
101
|
+
if (!lesson)
|
|
102
|
+
throw new Error('Lesson not found');
|
|
103
|
+
const distinctReporters = new Set(this.flags.filter((f) => f.lessonId === data.lessonId).map((f) => f.reporterAnonymousId));
|
|
104
|
+
lesson.flagCount = distinctReporters.size;
|
|
105
|
+
if (distinctReporters.size >= 3) {
|
|
106
|
+
lesson.hidden = true;
|
|
107
|
+
}
|
|
108
|
+
return { flagCount: distinctReporters.size };
|
|
109
|
+
}
|
|
110
|
+
async getModerationQueue() {
|
|
111
|
+
return this.shared
|
|
112
|
+
.filter((l) => l.hidden || l.flagCount >= 3)
|
|
113
|
+
.map((l) => ({
|
|
114
|
+
lesson: {
|
|
115
|
+
id: l.id,
|
|
116
|
+
category: l.category,
|
|
117
|
+
title: l.title,
|
|
118
|
+
content: l.content,
|
|
119
|
+
reputationScore: l.reputationScore,
|
|
120
|
+
flagCount: l.flagCount,
|
|
121
|
+
hidden: l.hidden,
|
|
122
|
+
},
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
async moderateLesson(lessonId, action) {
|
|
126
|
+
const lesson = this.shared.find((l) => l.id === lessonId);
|
|
127
|
+
if (!lesson)
|
|
128
|
+
return { success: false };
|
|
129
|
+
if (action === 'approve') {
|
|
130
|
+
lesson.hidden = false;
|
|
131
|
+
lesson.flagCount = 0;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
lesson.hidden = true;
|
|
135
|
+
}
|
|
136
|
+
return { success: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function cosineSimilarity(a, b) {
|
|
140
|
+
if (a.length !== b.length || a.length === 0)
|
|
141
|
+
return 0;
|
|
142
|
+
let dot = 0, magA = 0, magB = 0;
|
|
143
|
+
for (let i = 0; i < a.length; i++) {
|
|
144
|
+
dot += a[i] * b[i];
|
|
145
|
+
magA += a[i] * a[i];
|
|
146
|
+
magB += b[i] * b[i];
|
|
147
|
+
}
|
|
148
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
149
|
+
return denom === 0 ? 0 : dot / denom;
|
|
150
|
+
}
|
|
151
|
+
// ─── Simple TF-IDF Embedding ─────────────────────────────
|
|
152
|
+
/** Compute a lightweight hash-based embedding for text */
|
|
153
|
+
export function computeSimpleEmbedding(text, dimensions = 64) {
|
|
154
|
+
const words = text.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(Boolean);
|
|
155
|
+
const vec = new Float64Array(dimensions);
|
|
156
|
+
for (const word of words) {
|
|
157
|
+
const hash = createHash('md5').update(word).digest();
|
|
158
|
+
for (let i = 0; i < dimensions; i++) {
|
|
159
|
+
vec[i] += (hash[i % hash.length] - 128) / 128;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Normalize
|
|
163
|
+
let mag = 0;
|
|
164
|
+
for (let i = 0; i < dimensions; i++)
|
|
165
|
+
mag += vec[i] * vec[i];
|
|
166
|
+
mag = Math.sqrt(mag);
|
|
167
|
+
if (mag > 0)
|
|
168
|
+
for (let i = 0; i < dimensions; i++)
|
|
169
|
+
vec[i] /= mag;
|
|
170
|
+
return Array.from(vec);
|
|
171
|
+
}
|
|
172
|
+
const RATE_WINDOW_MS = 3_600_000; // 1 hour
|
|
173
|
+
const DEFAULT_RATE_LIMIT = 50;
|
|
174
|
+
class SharingRateLimiter {
|
|
175
|
+
buckets = new Map();
|
|
176
|
+
check(key, limit = DEFAULT_RATE_LIMIT) {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const bucket = this.buckets.get(key);
|
|
179
|
+
if (!bucket || now - bucket.windowStart >= RATE_WINDOW_MS) {
|
|
180
|
+
this.buckets.set(key, { count: 1, windowStart: now });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (bucket.count >= limit)
|
|
184
|
+
return false;
|
|
185
|
+
bucket.count++;
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
reset() {
|
|
189
|
+
this.buckets.clear();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export class CommunityService {
|
|
193
|
+
db;
|
|
194
|
+
transport;
|
|
195
|
+
anonIdManager;
|
|
196
|
+
lessonStore;
|
|
197
|
+
redactionPipeline;
|
|
198
|
+
rateLimiter = new SharingRateLimiter();
|
|
199
|
+
now;
|
|
200
|
+
constructor(db, options) {
|
|
201
|
+
this.db = db;
|
|
202
|
+
this.transport = options.transport;
|
|
203
|
+
this.anonIdManager = new AnonymousIdManager(db, { now: options.now });
|
|
204
|
+
this.lessonStore = new LessonStore(db);
|
|
205
|
+
this.redactionPipeline = new RedactionPipeline(options.redactionConfig);
|
|
206
|
+
this.now = options.now ?? (() => new Date());
|
|
207
|
+
}
|
|
208
|
+
// ─── Sharing Config (tenant-level) ─────────────────
|
|
209
|
+
getSharingConfig(tenantId) {
|
|
210
|
+
const row = this.db
|
|
211
|
+
.select()
|
|
212
|
+
.from(schema.sharingConfig)
|
|
213
|
+
.where(eq(schema.sharingConfig.tenantId, tenantId))
|
|
214
|
+
.get();
|
|
215
|
+
if (row) {
|
|
216
|
+
return {
|
|
217
|
+
tenantId: row.tenantId,
|
|
218
|
+
enabled: row.enabled,
|
|
219
|
+
humanReviewEnabled: row.humanReviewEnabled,
|
|
220
|
+
poolEndpoint: row.poolEndpoint,
|
|
221
|
+
anonymousContributorId: row.anonymousContributorId,
|
|
222
|
+
purgeToken: row.purgeToken,
|
|
223
|
+
rateLimitPerHour: row.rateLimitPerHour,
|
|
224
|
+
volumeAlertThreshold: row.volumeAlertThreshold,
|
|
225
|
+
updatedAt: row.updatedAt,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
tenantId,
|
|
230
|
+
enabled: false,
|
|
231
|
+
humanReviewEnabled: false,
|
|
232
|
+
poolEndpoint: null,
|
|
233
|
+
anonymousContributorId: null,
|
|
234
|
+
purgeToken: null,
|
|
235
|
+
rateLimitPerHour: 50,
|
|
236
|
+
volumeAlertThreshold: 100,
|
|
237
|
+
updatedAt: new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
updateSharingConfig(tenantId, updates) {
|
|
241
|
+
const now = this.now().toISOString();
|
|
242
|
+
const existing = this.db
|
|
243
|
+
.select()
|
|
244
|
+
.from(schema.sharingConfig)
|
|
245
|
+
.where(eq(schema.sharingConfig.tenantId, tenantId))
|
|
246
|
+
.get();
|
|
247
|
+
if (existing) {
|
|
248
|
+
const setObj = { updatedAt: now };
|
|
249
|
+
if (updates.enabled !== undefined)
|
|
250
|
+
setObj.enabled = updates.enabled;
|
|
251
|
+
if (updates.humanReviewEnabled !== undefined)
|
|
252
|
+
setObj.humanReviewEnabled = updates.humanReviewEnabled;
|
|
253
|
+
if (updates.poolEndpoint !== undefined)
|
|
254
|
+
setObj.poolEndpoint = updates.poolEndpoint;
|
|
255
|
+
if (updates.anonymousContributorId !== undefined)
|
|
256
|
+
setObj.anonymousContributorId = updates.anonymousContributorId;
|
|
257
|
+
if (updates.purgeToken !== undefined)
|
|
258
|
+
setObj.purgeToken = updates.purgeToken;
|
|
259
|
+
if (updates.rateLimitPerHour !== undefined)
|
|
260
|
+
setObj.rateLimitPerHour = updates.rateLimitPerHour;
|
|
261
|
+
if (updates.volumeAlertThreshold !== undefined)
|
|
262
|
+
setObj.volumeAlertThreshold = updates.volumeAlertThreshold;
|
|
263
|
+
this.db.update(schema.sharingConfig).set(setObj).where(eq(schema.sharingConfig.tenantId, tenantId)).run();
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
this.db.insert(schema.sharingConfig).values({
|
|
267
|
+
tenantId,
|
|
268
|
+
enabled: updates.enabled ?? false,
|
|
269
|
+
humanReviewEnabled: updates.humanReviewEnabled ?? false,
|
|
270
|
+
poolEndpoint: updates.poolEndpoint ?? null,
|
|
271
|
+
anonymousContributorId: updates.anonymousContributorId ?? null,
|
|
272
|
+
purgeToken: updates.purgeToken ?? null,
|
|
273
|
+
rateLimitPerHour: updates.rateLimitPerHour ?? 50,
|
|
274
|
+
volumeAlertThreshold: updates.volumeAlertThreshold ?? 100,
|
|
275
|
+
updatedAt: now,
|
|
276
|
+
}).run();
|
|
277
|
+
}
|
|
278
|
+
return this.getSharingConfig(tenantId);
|
|
279
|
+
}
|
|
280
|
+
// ─── Agent Sharing Config ──────────────────────────
|
|
281
|
+
getAgentSharingConfigs(tenantId) {
|
|
282
|
+
const rows = this.db
|
|
283
|
+
.select()
|
|
284
|
+
.from(schema.agentSharingConfig)
|
|
285
|
+
.where(eq(schema.agentSharingConfig.tenantId, tenantId))
|
|
286
|
+
.all();
|
|
287
|
+
return rows.map((row) => ({
|
|
288
|
+
tenantId: row.tenantId,
|
|
289
|
+
agentId: row.agentId,
|
|
290
|
+
enabled: row.enabled,
|
|
291
|
+
categories: JSON.parse(row.categories),
|
|
292
|
+
updatedAt: row.updatedAt,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
getStats(tenantId) {
|
|
296
|
+
const rows = this.db
|
|
297
|
+
.select()
|
|
298
|
+
.from(schema.sharingAuditLog)
|
|
299
|
+
.where(eq(schema.sharingAuditLog.tenantId, tenantId))
|
|
300
|
+
.all();
|
|
301
|
+
const shareEvents = rows.filter((r) => r.eventType === 'share');
|
|
302
|
+
const countShared = shareEvents.length;
|
|
303
|
+
const lastShared = shareEvents.length > 0
|
|
304
|
+
? shareEvents.sort((a, b) => b.timestamp.localeCompare(a.timestamp))[0].timestamp
|
|
305
|
+
: null;
|
|
306
|
+
const auditSummary = {};
|
|
307
|
+
for (const row of rows) {
|
|
308
|
+
auditSummary[row.eventType] = (auditSummary[row.eventType] ?? 0) + 1;
|
|
309
|
+
}
|
|
310
|
+
return { countShared, lastShared, auditSummary };
|
|
311
|
+
}
|
|
312
|
+
getAgentSharingConfig(tenantId, agentId) {
|
|
313
|
+
const row = this.db
|
|
314
|
+
.select()
|
|
315
|
+
.from(schema.agentSharingConfig)
|
|
316
|
+
.where(and(eq(schema.agentSharingConfig.tenantId, tenantId), eq(schema.agentSharingConfig.agentId, agentId)))
|
|
317
|
+
.get();
|
|
318
|
+
if (row) {
|
|
319
|
+
return {
|
|
320
|
+
tenantId: row.tenantId,
|
|
321
|
+
agentId: row.agentId,
|
|
322
|
+
enabled: row.enabled,
|
|
323
|
+
categories: JSON.parse(row.categories),
|
|
324
|
+
updatedAt: row.updatedAt,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
tenantId,
|
|
329
|
+
agentId,
|
|
330
|
+
enabled: false,
|
|
331
|
+
categories: [],
|
|
332
|
+
updatedAt: new Date().toISOString(),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
updateAgentSharingConfig(tenantId, agentId, updates) {
|
|
336
|
+
const now = this.now().toISOString();
|
|
337
|
+
const existing = this.db
|
|
338
|
+
.select()
|
|
339
|
+
.from(schema.agentSharingConfig)
|
|
340
|
+
.where(and(eq(schema.agentSharingConfig.tenantId, tenantId), eq(schema.agentSharingConfig.agentId, agentId)))
|
|
341
|
+
.get();
|
|
342
|
+
if (existing) {
|
|
343
|
+
const setObj = { updatedAt: now };
|
|
344
|
+
if (updates.enabled !== undefined)
|
|
345
|
+
setObj.enabled = updates.enabled;
|
|
346
|
+
if (updates.categories !== undefined)
|
|
347
|
+
setObj.categories = JSON.stringify(updates.categories);
|
|
348
|
+
this.db.update(schema.agentSharingConfig)
|
|
349
|
+
.set(setObj)
|
|
350
|
+
.where(and(eq(schema.agentSharingConfig.tenantId, tenantId), eq(schema.agentSharingConfig.agentId, agentId)))
|
|
351
|
+
.run();
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
this.db.insert(schema.agentSharingConfig).values({
|
|
355
|
+
tenantId,
|
|
356
|
+
agentId,
|
|
357
|
+
enabled: updates.enabled ?? false,
|
|
358
|
+
categories: JSON.stringify(updates.categories ?? []),
|
|
359
|
+
updatedAt: now,
|
|
360
|
+
}).run();
|
|
361
|
+
}
|
|
362
|
+
return this.getAgentSharingConfig(tenantId, agentId);
|
|
363
|
+
}
|
|
364
|
+
// ─── Deny List CRUD ────────────────────────────────
|
|
365
|
+
getDenyList(tenantId) {
|
|
366
|
+
return this.db
|
|
367
|
+
.select()
|
|
368
|
+
.from(schema.denyListRules)
|
|
369
|
+
.where(eq(schema.denyListRules.tenantId, tenantId))
|
|
370
|
+
.all()
|
|
371
|
+
.map((r) => ({
|
|
372
|
+
id: r.id,
|
|
373
|
+
tenantId: r.tenantId,
|
|
374
|
+
pattern: r.pattern,
|
|
375
|
+
isRegex: r.isRegex,
|
|
376
|
+
reason: r.reason,
|
|
377
|
+
createdAt: r.createdAt,
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
addDenyListRule(tenantId, pattern, isRegex, reason) {
|
|
381
|
+
const id = randomUUID();
|
|
382
|
+
const now = this.now().toISOString();
|
|
383
|
+
// Validate regex if isRegex
|
|
384
|
+
if (isRegex) {
|
|
385
|
+
try {
|
|
386
|
+
new RegExp(pattern);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
throw new Error(`Invalid regex pattern: ${pattern}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
this.db.insert(schema.denyListRules).values({
|
|
393
|
+
id,
|
|
394
|
+
tenantId,
|
|
395
|
+
pattern,
|
|
396
|
+
isRegex,
|
|
397
|
+
reason,
|
|
398
|
+
createdAt: now,
|
|
399
|
+
}).run();
|
|
400
|
+
return { id, tenantId, pattern, isRegex, reason, createdAt: now };
|
|
401
|
+
}
|
|
402
|
+
deleteDenyListRule(tenantId, ruleId) {
|
|
403
|
+
const result = this.db
|
|
404
|
+
.delete(schema.denyListRules)
|
|
405
|
+
.where(and(eq(schema.denyListRules.id, ruleId), eq(schema.denyListRules.tenantId, tenantId)))
|
|
406
|
+
.run();
|
|
407
|
+
return result.changes > 0;
|
|
408
|
+
}
|
|
409
|
+
// ─── Share (Story 4.1) ─────────────────────────────
|
|
410
|
+
async share(tenantId, lessonId, initiatedBy = 'system') {
|
|
411
|
+
// 1. Check tenant-level toggle (NO CACHING — always read from DB)
|
|
412
|
+
const tenantConfig = this.getSharingConfig(tenantId);
|
|
413
|
+
if (!tenantConfig.enabled) {
|
|
414
|
+
return { status: 'disabled', reason: 'Tenant sharing is disabled' };
|
|
415
|
+
}
|
|
416
|
+
// 2. Load lesson
|
|
417
|
+
const lesson = this.lessonStore.get(tenantId, lessonId);
|
|
418
|
+
if (!lesson) {
|
|
419
|
+
return { status: 'error', error: 'Lesson not found' };
|
|
420
|
+
}
|
|
421
|
+
// 3. Check agent-level toggle (hierarchical enforcement)
|
|
422
|
+
if (lesson.agentId) {
|
|
423
|
+
const agentConfig = this.getAgentSharingConfig(tenantId, lesson.agentId);
|
|
424
|
+
if (!agentConfig.enabled) {
|
|
425
|
+
return { status: 'disabled', reason: 'Agent sharing is disabled' };
|
|
426
|
+
}
|
|
427
|
+
// Check category-level toggle
|
|
428
|
+
const category = lesson.category;
|
|
429
|
+
if (agentConfig.categories.length > 0 && !agentConfig.categories.includes(category)) {
|
|
430
|
+
return { status: 'disabled', reason: `Category '${category}' is not enabled for this agent` };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// 4. Rate limiting
|
|
434
|
+
const rateLimit = tenantConfig.rateLimitPerHour || DEFAULT_RATE_LIMIT;
|
|
435
|
+
if (!this.rateLimiter.check(tenantId, rateLimit)) {
|
|
436
|
+
this.writeAuditLog(tenantId, {
|
|
437
|
+
eventType: 'rate',
|
|
438
|
+
lessonId,
|
|
439
|
+
initiatedBy,
|
|
440
|
+
});
|
|
441
|
+
return { status: 'rate_limited' };
|
|
442
|
+
}
|
|
443
|
+
// 5. Get deny list patterns for redaction context
|
|
444
|
+
const denyListRules = this.getDenyList(tenantId);
|
|
445
|
+
const denyListPatterns = denyListRules.map((r) => r.isRegex ? r.pattern : r.pattern);
|
|
446
|
+
// 6. Run redaction pipeline
|
|
447
|
+
const rawContent = createRawLessonContent(lesson.title, lesson.content, lesson.context ?? {});
|
|
448
|
+
const redactionResult = await this.redactionPipeline.process(rawContent, {
|
|
449
|
+
tenantId,
|
|
450
|
+
agentId: lesson.agentId,
|
|
451
|
+
category: lesson.category,
|
|
452
|
+
denyListPatterns,
|
|
453
|
+
knownTenantTerms: [],
|
|
454
|
+
});
|
|
455
|
+
if (redactionResult.status === 'blocked') {
|
|
456
|
+
this.writeAuditLog(tenantId, {
|
|
457
|
+
eventType: 'share',
|
|
458
|
+
lessonId,
|
|
459
|
+
initiatedBy,
|
|
460
|
+
});
|
|
461
|
+
return { status: 'blocked', reason: redactionResult.reason };
|
|
462
|
+
}
|
|
463
|
+
if (redactionResult.status === 'pending_review') {
|
|
464
|
+
this.writeAuditLog(tenantId, {
|
|
465
|
+
eventType: 'share',
|
|
466
|
+
lessonId,
|
|
467
|
+
initiatedBy,
|
|
468
|
+
});
|
|
469
|
+
return { status: 'pending_review', reviewId: redactionResult.reviewId };
|
|
470
|
+
}
|
|
471
|
+
if (redactionResult.status === 'error') {
|
|
472
|
+
return { status: 'error', error: redactionResult.error };
|
|
473
|
+
}
|
|
474
|
+
// 7. Generate anonymous contributor ID
|
|
475
|
+
const anonymousContributorId = this.anonIdManager.getOrRotateContributorId(tenantId);
|
|
476
|
+
// 7b. C2 FIX: Ensure purge token is registered with pool on first share
|
|
477
|
+
await this.ensurePurgeTokenRegistered(tenantId, anonymousContributorId);
|
|
478
|
+
// 8. Compute embedding
|
|
479
|
+
const redactedContent = redactionResult.content;
|
|
480
|
+
const embedding = computeSimpleEmbedding(`${redactedContent.title} ${redactedContent.content}`);
|
|
481
|
+
// 9. Send to pool
|
|
482
|
+
let poolResult;
|
|
483
|
+
try {
|
|
484
|
+
poolResult = await this.transport.share({
|
|
485
|
+
anonymousContributorId,
|
|
486
|
+
category: lesson.category,
|
|
487
|
+
title: redactedContent.title,
|
|
488
|
+
content: redactedContent.content,
|
|
489
|
+
embedding,
|
|
490
|
+
// C1 FIX: Do NOT send qualitySignals to pool — lesson.context may contain
|
|
491
|
+
// unredacted tenant-specific data that bypasses the redaction pipeline.
|
|
492
|
+
redactionApplied: true,
|
|
493
|
+
redactionFindingsCount: redactionResult.findings.length,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
return { status: 'error', error: err instanceof Error ? err.message : String(err) };
|
|
498
|
+
}
|
|
499
|
+
// 10. Write audit log
|
|
500
|
+
this.writeAuditLog(tenantId, {
|
|
501
|
+
eventType: 'share',
|
|
502
|
+
lessonId,
|
|
503
|
+
anonymousLessonId: poolResult.id,
|
|
504
|
+
redactionFindings: redactionResult.findings,
|
|
505
|
+
initiatedBy,
|
|
506
|
+
});
|
|
507
|
+
return {
|
|
508
|
+
status: 'shared',
|
|
509
|
+
anonymousLessonId: poolResult.id,
|
|
510
|
+
redactionFindings: redactionResult.findings,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// ─── C2 FIX: Purge Token Registration ──────────────
|
|
514
|
+
/**
|
|
515
|
+
* Ensure a purge token is registered with the pool for the given contributor ID.
|
|
516
|
+
* Creates and stores the token if one doesn't exist yet.
|
|
517
|
+
*/
|
|
518
|
+
async ensurePurgeTokenRegistered(tenantId, contributorId) {
|
|
519
|
+
const config = this.getSharingConfig(tenantId);
|
|
520
|
+
let purgeToken = config.purgeToken;
|
|
521
|
+
if (!purgeToken) {
|
|
522
|
+
purgeToken = randomUUID();
|
|
523
|
+
this.updateSharingConfig(tenantId, { purgeToken });
|
|
524
|
+
}
|
|
525
|
+
// Register with pool if transport supports it
|
|
526
|
+
if (this.transport.registerPurgeToken) {
|
|
527
|
+
try {
|
|
528
|
+
await this.transport.registerPurgeToken({
|
|
529
|
+
anonymousContributorId: contributorId,
|
|
530
|
+
token: purgeToken,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// Non-fatal: purge registration failure shouldn't block sharing
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// ─── Search (Story 4.2) ────────────────────────────
|
|
539
|
+
async search(tenantId, query, options, initiatedBy = 'system') {
|
|
540
|
+
const limit = Math.min(options?.limit ?? 50, 50);
|
|
541
|
+
const embedding = computeSimpleEmbedding(query);
|
|
542
|
+
const poolResult = await this.transport.search({
|
|
543
|
+
embedding,
|
|
544
|
+
category: options?.category,
|
|
545
|
+
minReputation: options?.minReputation,
|
|
546
|
+
limit,
|
|
547
|
+
});
|
|
548
|
+
// Strip any identifying metadata from results
|
|
549
|
+
const lessons = poolResult.results.map((r) => ({
|
|
550
|
+
id: r.lesson.id,
|
|
551
|
+
category: r.lesson.category,
|
|
552
|
+
title: r.lesson.title,
|
|
553
|
+
content: r.lesson.content,
|
|
554
|
+
reputationScore: r.lesson.reputationScore,
|
|
555
|
+
qualitySignals: r.lesson.qualitySignals ?? {},
|
|
556
|
+
}));
|
|
557
|
+
// Write audit log
|
|
558
|
+
this.writeAuditLog(tenantId, {
|
|
559
|
+
eventType: 'query',
|
|
560
|
+
queryText: query,
|
|
561
|
+
resultIds: lessons.map((l) => l.id),
|
|
562
|
+
initiatedBy,
|
|
563
|
+
});
|
|
564
|
+
return { lessons, total: lessons.length, query };
|
|
565
|
+
}
|
|
566
|
+
// ─── Kill Switch: Purge (Story 4.4) ────────────────
|
|
567
|
+
async purge(tenantId, confirmation, initiatedBy = 'admin') {
|
|
568
|
+
if (confirmation !== 'CONFIRM_PURGE') {
|
|
569
|
+
return { status: 'error', error: 'Confirmation required: pass "CONFIRM_PURGE"' };
|
|
570
|
+
}
|
|
571
|
+
// Get contributor ID from anonymous ID manager (same source as share())
|
|
572
|
+
const contributorId = this.anonIdManager.getOrRotateContributorId(tenantId);
|
|
573
|
+
const config = this.getSharingConfig(tenantId);
|
|
574
|
+
let purgeToken = config.purgeToken;
|
|
575
|
+
if (!purgeToken) {
|
|
576
|
+
// No purge token means no shares have happened — create one now for the purge call
|
|
577
|
+
purgeToken = randomUUID();
|
|
578
|
+
this.updateSharingConfig(tenantId, { purgeToken });
|
|
579
|
+
// Register with pool
|
|
580
|
+
if (this.transport.registerPurgeToken) {
|
|
581
|
+
try {
|
|
582
|
+
await this.transport.registerPurgeToken({
|
|
583
|
+
anonymousContributorId: contributorId,
|
|
584
|
+
token: purgeToken,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// Non-fatal
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const result = await this.transport.purge({
|
|
594
|
+
anonymousContributorId: contributorId,
|
|
595
|
+
token: purgeToken,
|
|
596
|
+
});
|
|
597
|
+
// Retire old contributor ID by invalidating it (set validUntil to past)
|
|
598
|
+
// The next call to getOrRotateContributorId will create a new one
|
|
599
|
+
const pastIso = new Date(this.now().getTime() - 1000).toISOString();
|
|
600
|
+
// H4 FIX: agentId is stored hashed in the DB
|
|
601
|
+
const hashedContributorKey = createHash('sha256')
|
|
602
|
+
.update(`agentlens-anon-id-salt-v1:${tenantId}:__contributor__`)
|
|
603
|
+
.digest('hex');
|
|
604
|
+
this.db
|
|
605
|
+
.update(schema.anonymousIdMap)
|
|
606
|
+
.set({ validUntil: pastIso })
|
|
607
|
+
.where(and(eq(schema.anonymousIdMap.tenantId, tenantId), eq(schema.anonymousIdMap.agentId, hashedContributorKey)))
|
|
608
|
+
.run();
|
|
609
|
+
// Generate new purge token
|
|
610
|
+
const newPurgeToken = randomUUID();
|
|
611
|
+
this.updateSharingConfig(tenantId, {
|
|
612
|
+
enabled: false,
|
|
613
|
+
purgeToken: newPurgeToken,
|
|
614
|
+
});
|
|
615
|
+
this.writeAuditLog(tenantId, {
|
|
616
|
+
eventType: 'purge',
|
|
617
|
+
initiatedBy,
|
|
618
|
+
});
|
|
619
|
+
return { status: 'purged', deleted: result.deleted };
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
return { status: 'error', error: err instanceof Error ? err.message : String(err) };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async verifyPurge(tenantId) {
|
|
626
|
+
const config = this.getSharingConfig(tenantId);
|
|
627
|
+
// Check with the OLD contributor ID — but since we retired it, we check the current one
|
|
628
|
+
// After purge, the config has a new ID, so we check the pool for lessons from this tenant
|
|
629
|
+
// The old ID is gone, new ID should have 0 lessons
|
|
630
|
+
const contributorId = config.anonymousContributorId;
|
|
631
|
+
if (!contributorId)
|
|
632
|
+
return { count: 0 };
|
|
633
|
+
return this.transport.count(contributorId);
|
|
634
|
+
}
|
|
635
|
+
// ─── Reputation: Rate (Story 4.4) ─────────────────
|
|
636
|
+
async rate(tenantId, lessonId, delta, reason, initiatedBy = 'system') {
|
|
637
|
+
// Get anonymous voter ID
|
|
638
|
+
const voterAnonymousId = this.anonIdManager.getOrRotateContributorId(tenantId);
|
|
639
|
+
try {
|
|
640
|
+
const result = await this.transport.rate({
|
|
641
|
+
lessonId,
|
|
642
|
+
voterAnonymousId,
|
|
643
|
+
delta,
|
|
644
|
+
reason,
|
|
645
|
+
});
|
|
646
|
+
this.writeAuditLog(tenantId, {
|
|
647
|
+
eventType: 'rate',
|
|
648
|
+
anonymousLessonId: lessonId,
|
|
649
|
+
initiatedBy,
|
|
650
|
+
});
|
|
651
|
+
return { status: 'rated', reputationScore: result.reputationScore };
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
return { status: 'error', error: err instanceof Error ? err.message : String(err) };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// ─── Flagging (Story 4.5) ─────────────────────────
|
|
658
|
+
async flag(tenantId, lessonId, reason, initiatedBy = 'system') {
|
|
659
|
+
const reporterAnonymousId = this.anonIdManager.getOrRotateContributorId(tenantId);
|
|
660
|
+
try {
|
|
661
|
+
const result = await this.transport.flag({
|
|
662
|
+
lessonId,
|
|
663
|
+
reporterAnonymousId,
|
|
664
|
+
reason,
|
|
665
|
+
});
|
|
666
|
+
this.writeAuditLog(tenantId, {
|
|
667
|
+
eventType: 'flag',
|
|
668
|
+
anonymousLessonId: lessonId,
|
|
669
|
+
initiatedBy,
|
|
670
|
+
});
|
|
671
|
+
return { status: 'flagged', flagCount: result.flagCount };
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
return { status: 'error', error: err instanceof Error ? err.message : String(err) };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// ─── Moderation (Story 4.5) ───────────────────────
|
|
678
|
+
async getModerationQueue() {
|
|
679
|
+
return this.transport.getModerationQueue();
|
|
680
|
+
}
|
|
681
|
+
async moderateLesson(lessonId, action) {
|
|
682
|
+
return this.transport.moderateLesson(lessonId, action);
|
|
683
|
+
}
|
|
684
|
+
// ─── Human Review Queue (Story 4.5) ───────────────
|
|
685
|
+
getReviewQueue(tenantId) {
|
|
686
|
+
const now = this.now().toISOString();
|
|
687
|
+
const rows = this.db
|
|
688
|
+
.select()
|
|
689
|
+
.from(schema.sharingReviewQueue)
|
|
690
|
+
.where(and(eq(schema.sharingReviewQueue.tenantId, tenantId), eq(schema.sharingReviewQueue.status, 'pending')))
|
|
691
|
+
.all();
|
|
692
|
+
// Filter out expired items and mark them expired
|
|
693
|
+
const result = [];
|
|
694
|
+
for (const row of rows) {
|
|
695
|
+
if (row.expiresAt <= now) {
|
|
696
|
+
// Mark as expired
|
|
697
|
+
this.db
|
|
698
|
+
.update(schema.sharingReviewQueue)
|
|
699
|
+
.set({ status: 'expired' })
|
|
700
|
+
.where(eq(schema.sharingReviewQueue.id, row.id))
|
|
701
|
+
.run();
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
result.push({
|
|
705
|
+
id: row.id,
|
|
706
|
+
lessonId: row.lessonId,
|
|
707
|
+
originalTitle: row.originalTitle,
|
|
708
|
+
redactedTitle: row.redactedTitle,
|
|
709
|
+
status: row.status,
|
|
710
|
+
createdAt: row.createdAt,
|
|
711
|
+
expiresAt: row.expiresAt,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
async approveReviewItem(tenantId, reviewId, reviewedBy) {
|
|
718
|
+
const item = this.db
|
|
719
|
+
.select()
|
|
720
|
+
.from(schema.sharingReviewQueue)
|
|
721
|
+
.where(and(eq(schema.sharingReviewQueue.id, reviewId), eq(schema.sharingReviewQueue.tenantId, tenantId)))
|
|
722
|
+
.get();
|
|
723
|
+
if (!item)
|
|
724
|
+
return { status: 'error', error: 'Review item not found' };
|
|
725
|
+
if (item.status !== 'pending')
|
|
726
|
+
return { status: 'error', error: `Cannot approve item in status: ${item.status}` };
|
|
727
|
+
// Check expiry
|
|
728
|
+
if (item.expiresAt <= this.now().toISOString()) {
|
|
729
|
+
this.db
|
|
730
|
+
.update(schema.sharingReviewQueue)
|
|
731
|
+
.set({ status: 'expired' })
|
|
732
|
+
.where(eq(schema.sharingReviewQueue.id, reviewId))
|
|
733
|
+
.run();
|
|
734
|
+
return { status: 'error', error: 'Review item has expired' };
|
|
735
|
+
}
|
|
736
|
+
// Mark as approved
|
|
737
|
+
this.db
|
|
738
|
+
.update(schema.sharingReviewQueue)
|
|
739
|
+
.set({ status: 'approved', reviewedBy, reviewedAt: this.now().toISOString() })
|
|
740
|
+
.where(eq(schema.sharingReviewQueue.id, reviewId))
|
|
741
|
+
.run();
|
|
742
|
+
// Send to pool
|
|
743
|
+
const contributorId = this.anonIdManager.getOrRotateContributorId(tenantId);
|
|
744
|
+
const embedding = computeSimpleEmbedding(`${item.redactedTitle} ${item.redactedContent}`);
|
|
745
|
+
try {
|
|
746
|
+
await this.transport.share({
|
|
747
|
+
anonymousContributorId: contributorId,
|
|
748
|
+
category: 'general',
|
|
749
|
+
title: item.redactedTitle,
|
|
750
|
+
content: item.redactedContent,
|
|
751
|
+
embedding,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
// Pool error shouldn't fail the approval
|
|
756
|
+
}
|
|
757
|
+
return { status: 'approved' };
|
|
758
|
+
}
|
|
759
|
+
async rejectReviewItem(tenantId, reviewId, reviewedBy) {
|
|
760
|
+
const item = this.db
|
|
761
|
+
.select()
|
|
762
|
+
.from(schema.sharingReviewQueue)
|
|
763
|
+
.where(and(eq(schema.sharingReviewQueue.id, reviewId), eq(schema.sharingReviewQueue.tenantId, tenantId)))
|
|
764
|
+
.get();
|
|
765
|
+
if (!item)
|
|
766
|
+
return { status: 'error', error: 'Review item not found' };
|
|
767
|
+
if (item.status !== 'pending')
|
|
768
|
+
return { status: 'error', error: `Cannot reject item in status: ${item.status}` };
|
|
769
|
+
this.db
|
|
770
|
+
.update(schema.sharingReviewQueue)
|
|
771
|
+
.set({ status: 'rejected', reviewedBy, reviewedAt: this.now().toISOString() })
|
|
772
|
+
.where(eq(schema.sharingReviewQueue.id, reviewId))
|
|
773
|
+
.run();
|
|
774
|
+
return { status: 'rejected' };
|
|
775
|
+
}
|
|
776
|
+
// ─── Audit Log ─────────────────────────────────────
|
|
777
|
+
writeAuditLog(tenantId, data) {
|
|
778
|
+
this.db.insert(schema.sharingAuditLog).values({
|
|
779
|
+
id: randomUUID(),
|
|
780
|
+
tenantId,
|
|
781
|
+
eventType: data.eventType,
|
|
782
|
+
lessonId: data.lessonId ?? null,
|
|
783
|
+
anonymousLessonId: data.anonymousLessonId ?? null,
|
|
784
|
+
lessonHash: null,
|
|
785
|
+
redactionFindings: data.redactionFindings ? JSON.stringify(data.redactionFindings) : null,
|
|
786
|
+
queryText: data.queryText ?? null,
|
|
787
|
+
resultIds: data.resultIds ? JSON.stringify(data.resultIds) : null,
|
|
788
|
+
poolEndpoint: null,
|
|
789
|
+
initiatedBy: data.initiatedBy,
|
|
790
|
+
timestamp: this.now().toISOString(),
|
|
791
|
+
}).run();
|
|
792
|
+
}
|
|
793
|
+
getAuditLog(tenantId, limit = 50) {
|
|
794
|
+
const rows = this.db
|
|
795
|
+
.select()
|
|
796
|
+
.from(schema.sharingAuditLog)
|
|
797
|
+
.where(eq(schema.sharingAuditLog.tenantId, tenantId))
|
|
798
|
+
.limit(limit)
|
|
799
|
+
.all();
|
|
800
|
+
return rows.map((r) => ({
|
|
801
|
+
id: r.id,
|
|
802
|
+
tenantId: r.tenantId,
|
|
803
|
+
eventType: r.eventType,
|
|
804
|
+
lessonId: r.lessonId ?? undefined,
|
|
805
|
+
anonymousLessonId: r.anonymousLessonId ?? undefined,
|
|
806
|
+
lessonHash: r.lessonHash ?? undefined,
|
|
807
|
+
redactionFindings: r.redactionFindings ? JSON.parse(r.redactionFindings) : undefined,
|
|
808
|
+
queryText: r.queryText ?? undefined,
|
|
809
|
+
resultIds: r.resultIds ? JSON.parse(r.resultIds) : undefined,
|
|
810
|
+
poolEndpoint: r.poolEndpoint ?? undefined,
|
|
811
|
+
initiatedBy: r.initiatedBy ?? 'system',
|
|
812
|
+
timestamp: r.timestamp,
|
|
813
|
+
}));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
//# sourceMappingURL=community-service.js.map
|