@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.
Files changed (111) hide show
  1. package/dist/db/anonymous-id-manager.d.ts +44 -0
  2. package/dist/db/anonymous-id-manager.d.ts.map +1 -0
  3. package/dist/db/anonymous-id-manager.js +90 -0
  4. package/dist/db/anonymous-id-manager.js.map +1 -0
  5. package/dist/db/capability-store.d.ts +82 -0
  6. package/dist/db/capability-store.d.ts.map +1 -0
  7. package/dist/db/capability-store.js +221 -0
  8. package/dist/db/capability-store.js.map +1 -0
  9. package/dist/db/migrate.d.ts.map +1 -1
  10. package/dist/db/migrate.js +136 -0
  11. package/dist/db/migrate.js.map +1 -1
  12. package/dist/db/schema.sqlite.d.ts +1663 -2
  13. package/dist/db/schema.sqlite.d.ts.map +1 -1
  14. package/dist/db/schema.sqlite.js +135 -1
  15. package/dist/db/schema.sqlite.js.map +1 -1
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +48 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/redaction/human-review-layer.d.ts +37 -0
  21. package/dist/lib/redaction/human-review-layer.d.ts.map +1 -0
  22. package/dist/lib/redaction/human-review-layer.js +62 -0
  23. package/dist/lib/redaction/human-review-layer.js.map +1 -0
  24. package/dist/lib/redaction/index.d.ts +12 -0
  25. package/dist/lib/redaction/index.d.ts.map +1 -0
  26. package/dist/lib/redaction/index.js +12 -0
  27. package/dist/lib/redaction/index.js.map +1 -0
  28. package/dist/lib/redaction/pii-detection-layer.d.ts +30 -0
  29. package/dist/lib/redaction/pii-detection-layer.d.ts.map +1 -0
  30. package/dist/lib/redaction/pii-detection-layer.js +183 -0
  31. package/dist/lib/redaction/pii-detection-layer.js.map +1 -0
  32. package/dist/lib/redaction/pipeline.d.ts +26 -0
  33. package/dist/lib/redaction/pipeline.d.ts.map +1 -0
  34. package/dist/lib/redaction/pipeline.js +91 -0
  35. package/dist/lib/redaction/pipeline.js.map +1 -0
  36. package/dist/lib/redaction/secret-detection-layer.d.ts +10 -0
  37. package/dist/lib/redaction/secret-detection-layer.d.ts.map +1 -0
  38. package/dist/lib/redaction/secret-detection-layer.js +79 -0
  39. package/dist/lib/redaction/secret-detection-layer.js.map +1 -0
  40. package/dist/lib/redaction/secret-patterns.d.ts +29 -0
  41. package/dist/lib/redaction/secret-patterns.d.ts.map +1 -0
  42. package/dist/lib/redaction/secret-patterns.js +133 -0
  43. package/dist/lib/redaction/secret-patterns.js.map +1 -0
  44. package/dist/lib/redaction/semantic-denylist-layer.d.ts +10 -0
  45. package/dist/lib/redaction/semantic-denylist-layer.d.ts.map +1 -0
  46. package/dist/lib/redaction/semantic-denylist-layer.js +64 -0
  47. package/dist/lib/redaction/semantic-denylist-layer.js.map +1 -0
  48. package/dist/lib/redaction/tenant-deidentification-layer.d.ts +10 -0
  49. package/dist/lib/redaction/tenant-deidentification-layer.d.ts.map +1 -0
  50. package/dist/lib/redaction/tenant-deidentification-layer.js +64 -0
  51. package/dist/lib/redaction/tenant-deidentification-layer.js.map +1 -0
  52. package/dist/lib/redaction/url-path-scrubbing-layer.d.ts +14 -0
  53. package/dist/lib/redaction/url-path-scrubbing-layer.d.ts.map +1 -0
  54. package/dist/lib/redaction/url-path-scrubbing-layer.js +156 -0
  55. package/dist/lib/redaction/url-path-scrubbing-layer.js.map +1 -0
  56. package/dist/routes/agents.d.ts.map +1 -1
  57. package/dist/routes/agents.js +3 -9
  58. package/dist/routes/agents.js.map +1 -1
  59. package/dist/routes/audit.d.ts +15 -0
  60. package/dist/routes/audit.d.ts.map +1 -0
  61. package/dist/routes/audit.js +177 -0
  62. package/dist/routes/audit.js.map +1 -0
  63. package/dist/routes/capabilities-top.d.ts +15 -0
  64. package/dist/routes/capabilities-top.d.ts.map +1 -0
  65. package/dist/routes/capabilities-top.js +77 -0
  66. package/dist/routes/capabilities-top.js.map +1 -0
  67. package/dist/routes/capabilities.d.ts +15 -0
  68. package/dist/routes/capabilities.d.ts.map +1 -0
  69. package/dist/routes/capabilities.js +86 -0
  70. package/dist/routes/capabilities.js.map +1 -0
  71. package/dist/routes/community.d.ts +24 -0
  72. package/dist/routes/community.d.ts.map +1 -0
  73. package/dist/routes/community.js +272 -0
  74. package/dist/routes/community.js.map +1 -0
  75. package/dist/routes/delegation.d.ts +20 -0
  76. package/dist/routes/delegation.d.ts.map +1 -0
  77. package/dist/routes/delegation.js +108 -0
  78. package/dist/routes/delegation.js.map +1 -0
  79. package/dist/routes/delegations-top.d.ts +12 -0
  80. package/dist/routes/delegations-top.d.ts.map +1 -0
  81. package/dist/routes/delegations-top.js +43 -0
  82. package/dist/routes/delegations-top.js.map +1 -0
  83. package/dist/routes/discovery.d.ts +19 -0
  84. package/dist/routes/discovery.d.ts.map +1 -0
  85. package/dist/routes/discovery.js +96 -0
  86. package/dist/routes/discovery.js.map +1 -0
  87. package/dist/routes/redaction-test.d.ts +14 -0
  88. package/dist/routes/redaction-test.d.ts.map +1 -0
  89. package/dist/routes/redaction-test.js +33 -0
  90. package/dist/routes/redaction-test.js.map +1 -0
  91. package/dist/routes/trust.d.ts +16 -0
  92. package/dist/routes/trust.d.ts.map +1 -0
  93. package/dist/routes/trust.js +23 -0
  94. package/dist/routes/trust.js.map +1 -0
  95. package/dist/services/community-service.d.ts +283 -0
  96. package/dist/services/community-service.d.ts.map +1 -0
  97. package/dist/services/community-service.js +816 -0
  98. package/dist/services/community-service.js.map +1 -0
  99. package/dist/services/delegation-service.d.ts +149 -0
  100. package/dist/services/delegation-service.d.ts.map +1 -0
  101. package/dist/services/delegation-service.js +605 -0
  102. package/dist/services/delegation-service.js.map +1 -0
  103. package/dist/services/discovery-service.d.ts +39 -0
  104. package/dist/services/discovery-service.d.ts.map +1 -0
  105. package/dist/services/discovery-service.js +186 -0
  106. package/dist/services/discovery-service.js.map +1 -0
  107. package/dist/services/trust-service.d.ts +59 -0
  108. package/dist/services/trust-service.d.ts.map +1 -0
  109. package/dist/services/trust-service.js +139 -0
  110. package/dist/services/trust-service.js.map +1 -0
  111. 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