@dupecom/botcha-cloudflare 0.16.0 → 0.18.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.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * TAP Agent Reputation Scoring
3
+ *
4
+ * The "credit score" for AI agents. Persistent identity enables behavioral
5
+ * tracking over time, producing trust scores that unlock higher rate limits,
6
+ * faster verification, and access to sensitive APIs.
7
+ *
8
+ * Scoring model:
9
+ * - Base score: 500 (neutral, no history)
10
+ * - Range: 0..1000
11
+ * - Events adjust score via weighted deltas
12
+ * - Decay: scores trend toward 500 over time without activity (mean reversion)
13
+ * - Tiers: untrusted (0-199), low (200-399), neutral (400-599),
14
+ * good (600-799), excellent (800-1000)
15
+ *
16
+ * Event categories:
17
+ * - verification: challenge solved, auth success/failure
18
+ * - attestation: issued, verified, revoked
19
+ * - delegation: granted, received, revoked
20
+ * - session: created, expired normally, force-terminated
21
+ * - violation: rate limit exceeded, invalid token, abuse detected
22
+ * - endorsement: explicit trust signal from another agent or app
23
+ *
24
+ * KV storage (SESSIONS namespace):
25
+ * - reputation:{agent_id} — ReputationScore record
26
+ * - reputation_events:{agent_id} — Array of event IDs (index)
27
+ * - reputation_event:{event_id} — Individual ReputationEvent (with TTL)
28
+ */
29
+ import { getTAPAgent } from './tap-agents.js';
30
+ // ============ CONSTANTS ============
31
+ const BASE_SCORE = 500;
32
+ const MIN_SCORE = 0;
33
+ const MAX_SCORE = 1000;
34
+ /** How long individual events are retained in KV (90 days) */
35
+ const EVENT_TTL_SECONDS = 90 * 24 * 3600;
36
+ /** Max events to keep in the index per agent */
37
+ const MAX_EVENT_INDEX = 1000;
38
+ /** Score deltas per action — positive values increase score, negative decrease */
39
+ const ACTION_DELTAS = {
40
+ // verification (+/-)
41
+ challenge_solved: 5,
42
+ challenge_failed: -3,
43
+ auth_success: 3,
44
+ auth_failure: -5,
45
+ // attestation
46
+ attestation_issued: 8,
47
+ attestation_verified: 4,
48
+ attestation_revoked: -10,
49
+ // delegation
50
+ delegation_granted: 6,
51
+ delegation_received: 10,
52
+ delegation_revoked: -8,
53
+ // session
54
+ session_created: 2,
55
+ session_expired: 1, // normal expiry is fine
56
+ session_terminated: -5, // force-terminated is suspicious
57
+ // violation
58
+ rate_limit_exceeded: -15,
59
+ invalid_token: -10,
60
+ abuse_detected: -50,
61
+ // endorsement
62
+ endorsement_received: 20,
63
+ endorsement_given: 3,
64
+ };
65
+ // ============ TIER LOGIC ============
66
+ export function getTier(score) {
67
+ if (score < 200)
68
+ return 'untrusted';
69
+ if (score < 400)
70
+ return 'low';
71
+ if (score < 600)
72
+ return 'neutral';
73
+ if (score < 800)
74
+ return 'good';
75
+ return 'excellent';
76
+ }
77
+ // ============ SCORE OPERATIONS ============
78
+ /**
79
+ * Clamp score to [MIN_SCORE, MAX_SCORE].
80
+ */
81
+ function clamp(value) {
82
+ return Math.max(MIN_SCORE, Math.min(MAX_SCORE, value));
83
+ }
84
+ /**
85
+ * Create a fresh reputation score for a new agent.
86
+ */
87
+ function createDefaultScore(agentId, appId) {
88
+ const now = Date.now();
89
+ return {
90
+ agent_id: agentId,
91
+ app_id: appId,
92
+ score: BASE_SCORE,
93
+ tier: getTier(BASE_SCORE),
94
+ event_count: 0,
95
+ positive_events: 0,
96
+ negative_events: 0,
97
+ last_event_at: null,
98
+ created_at: now,
99
+ updated_at: now,
100
+ category_scores: {
101
+ verification: 0,
102
+ attestation: 0,
103
+ delegation: 0,
104
+ session: 0,
105
+ violation: 0,
106
+ endorsement: 0,
107
+ },
108
+ };
109
+ }
110
+ /**
111
+ * Apply mean-reversion decay. For every 7 days since last activity,
112
+ * nudge the score 1% toward BASE_SCORE.
113
+ */
114
+ export function applyDecay(score) {
115
+ if (!score.last_event_at)
116
+ return score;
117
+ const daysSinceActivity = (Date.now() - score.last_event_at) / (1000 * 60 * 60 * 24);
118
+ const decayPeriods = Math.floor(daysSinceActivity / 7);
119
+ if (decayPeriods <= 0)
120
+ return score;
121
+ const diff = score.score - BASE_SCORE;
122
+ // Each period decays 1% of distance from base
123
+ const decayFactor = Math.pow(0.99, decayPeriods);
124
+ const newScore = clamp(Math.round(BASE_SCORE + diff * decayFactor));
125
+ return {
126
+ ...score,
127
+ score: newScore,
128
+ tier: getTier(newScore),
129
+ updated_at: Date.now(),
130
+ };
131
+ }
132
+ // ============ CORE FUNCTIONS ============
133
+ /**
134
+ * Get the reputation score for an agent. Creates a default score if none exists.
135
+ */
136
+ export async function getReputationScore(sessions, agents, agentId, appId) {
137
+ try {
138
+ // Verify agent exists
139
+ const agentResult = await getTAPAgent(agents, agentId);
140
+ if (!agentResult.success || !agentResult.agent) {
141
+ return { success: false, error: 'Agent not found' };
142
+ }
143
+ const data = await sessions.get(`reputation:${agentId}`, 'text');
144
+ if (!data) {
145
+ // Return default score (don't persist until first event)
146
+ const defaultScore = createDefaultScore(agentId, appId);
147
+ return { success: true, score: defaultScore };
148
+ }
149
+ let score = JSON.parse(data);
150
+ // Apply decay
151
+ score = applyDecay(score);
152
+ return { success: true, score };
153
+ }
154
+ catch (error) {
155
+ console.error('Failed to get reputation score:', error);
156
+ return { success: false, error: 'Internal server error' };
157
+ }
158
+ }
159
+ /**
160
+ * Record a reputation event for an agent.
161
+ * Creates the reputation record if it doesn't exist yet.
162
+ */
163
+ export async function recordReputationEvent(sessions, agents, appId, options) {
164
+ try {
165
+ // Validate agent exists and belongs to app
166
+ const agentResult = await getTAPAgent(agents, options.agent_id);
167
+ if (!agentResult.success || !agentResult.agent) {
168
+ return { success: false, error: 'Agent not found' };
169
+ }
170
+ if (agentResult.agent.app_id !== appId) {
171
+ return { success: false, error: 'Agent does not belong to this app' };
172
+ }
173
+ // Validate source agent if provided (for endorsements)
174
+ if (options.source_agent_id) {
175
+ const sourceResult = await getTAPAgent(agents, options.source_agent_id);
176
+ if (!sourceResult.success || !sourceResult.agent) {
177
+ return { success: false, error: 'Source agent not found' };
178
+ }
179
+ if (sourceResult.agent.app_id !== appId) {
180
+ return { success: false, error: 'Source agent does not belong to this app' };
181
+ }
182
+ // Cannot endorse yourself
183
+ if (options.source_agent_id === options.agent_id) {
184
+ return { success: false, error: 'Agent cannot endorse itself' };
185
+ }
186
+ }
187
+ // Validate action belongs to category
188
+ if (!isValidCategoryAction(options.category, options.action)) {
189
+ return { success: false, error: `Action "${options.action}" does not belong to category "${options.category}"` };
190
+ }
191
+ // Get or create score
192
+ const data = await sessions.get(`reputation:${options.agent_id}`, 'text');
193
+ let score = data
194
+ ? JSON.parse(data)
195
+ : createDefaultScore(options.agent_id, appId);
196
+ // Apply decay before recording event
197
+ score = applyDecay(score);
198
+ // Calculate delta
199
+ const delta = ACTION_DELTAS[options.action] ?? 0;
200
+ const scoreBefore = score.score;
201
+ const scoreAfter = clamp(scoreBefore + delta);
202
+ // Create event record
203
+ const eventId = crypto.randomUUID();
204
+ const now = Date.now();
205
+ const event = {
206
+ event_id: eventId,
207
+ agent_id: options.agent_id,
208
+ app_id: appId,
209
+ category: options.category,
210
+ action: options.action,
211
+ delta,
212
+ score_before: scoreBefore,
213
+ score_after: scoreAfter,
214
+ source_agent_id: options.source_agent_id,
215
+ metadata: options.metadata,
216
+ created_at: now,
217
+ };
218
+ // Update score
219
+ score.score = scoreAfter;
220
+ score.tier = getTier(scoreAfter);
221
+ score.event_count += 1;
222
+ if (delta > 0)
223
+ score.positive_events += 1;
224
+ if (delta < 0)
225
+ score.negative_events += 1;
226
+ score.last_event_at = now;
227
+ score.updated_at = now;
228
+ score.category_scores[options.category] += delta;
229
+ // Persist score
230
+ await sessions.put(`reputation:${options.agent_id}`, JSON.stringify(score));
231
+ // Persist event with TTL
232
+ await sessions.put(`reputation_event:${eventId}`, JSON.stringify(event), { expirationTtl: EVENT_TTL_SECONDS });
233
+ // Update event index
234
+ await updateEventIndex(sessions, options.agent_id, eventId);
235
+ return { success: true, event, score };
236
+ }
237
+ catch (error) {
238
+ console.error('Failed to record reputation event:', error);
239
+ return { success: false, error: 'Internal server error' };
240
+ }
241
+ }
242
+ /**
243
+ * List reputation events for an agent.
244
+ * Returns the most recent events (up to limit).
245
+ */
246
+ export async function listReputationEvents(sessions, agentId, options) {
247
+ try {
248
+ const limit = Math.min(options?.limit ?? 50, 100);
249
+ const indexKey = `reputation_events:${agentId}`;
250
+ const indexData = await sessions.get(indexKey, 'text');
251
+ const eventIds = indexData ? JSON.parse(indexData) : [];
252
+ // Most recent first (index is append-order, reverse for recency)
253
+ const recentIds = eventIds.slice(-limit).reverse();
254
+ const events = [];
255
+ for (const id of recentIds) {
256
+ const data = await sessions.get(`reputation_event:${id}`, 'text');
257
+ if (data) {
258
+ const event = JSON.parse(data);
259
+ if (!options?.category || event.category === options.category) {
260
+ events.push(event);
261
+ }
262
+ }
263
+ }
264
+ return { success: true, events, count: events.length };
265
+ }
266
+ catch (error) {
267
+ console.error('Failed to list reputation events:', error);
268
+ return { success: false, error: 'Internal server error' };
269
+ }
270
+ }
271
+ /**
272
+ * Reset an agent's reputation to default. Used by app admins.
273
+ */
274
+ export async function resetReputation(sessions, agents, agentId, appId) {
275
+ try {
276
+ // Verify agent exists and belongs to app
277
+ const agentResult = await getTAPAgent(agents, agentId);
278
+ if (!agentResult.success || !agentResult.agent) {
279
+ return { success: false, error: 'Agent not found' };
280
+ }
281
+ if (agentResult.agent.app_id !== appId) {
282
+ return { success: false, error: 'Agent does not belong to this app' };
283
+ }
284
+ const score = createDefaultScore(agentId, appId);
285
+ await sessions.put(`reputation:${agentId}`, JSON.stringify(score));
286
+ // Clear event index (events themselves expire via TTL)
287
+ await sessions.put(`reputation_events:${agentId}`, JSON.stringify([]));
288
+ return { success: true, score };
289
+ }
290
+ catch (error) {
291
+ console.error('Failed to reset reputation:', error);
292
+ return { success: false, error: 'Internal server error' };
293
+ }
294
+ }
295
+ // ============ VALIDATION ============
296
+ const CATEGORY_ACTIONS = {
297
+ verification: ['challenge_solved', 'challenge_failed', 'auth_success', 'auth_failure'],
298
+ attestation: ['attestation_issued', 'attestation_verified', 'attestation_revoked'],
299
+ delegation: ['delegation_granted', 'delegation_received', 'delegation_revoked'],
300
+ session: ['session_created', 'session_expired', 'session_terminated'],
301
+ violation: ['rate_limit_exceeded', 'invalid_token', 'abuse_detected'],
302
+ endorsement: ['endorsement_received', 'endorsement_given'],
303
+ };
304
+ export function isValidCategoryAction(category, action) {
305
+ const validActions = CATEGORY_ACTIONS[category];
306
+ return validActions ? validActions.includes(action) : false;
307
+ }
308
+ export function isValidCategory(category) {
309
+ return category in CATEGORY_ACTIONS;
310
+ }
311
+ export function isValidAction(action) {
312
+ return action in ACTION_DELTAS;
313
+ }
314
+ // ============ UTILITY ============
315
+ async function updateEventIndex(sessions, agentId, eventId) {
316
+ try {
317
+ const key = `reputation_events:${agentId}`;
318
+ const data = await sessions.get(key, 'text');
319
+ let ids = data ? JSON.parse(data) : [];
320
+ ids.push(eventId);
321
+ // Trim to max size (keep most recent)
322
+ if (ids.length > MAX_EVENT_INDEX) {
323
+ ids = ids.slice(-MAX_EVENT_INDEX);
324
+ }
325
+ await sessions.put(key, JSON.stringify(ids));
326
+ }
327
+ catch (error) {
328
+ console.error('Failed to update reputation event index:', error);
329
+ }
330
+ }
331
+ // ============ EXPORTS ============
332
+ export default {
333
+ getReputationScore,
334
+ recordReputationEvent,
335
+ listReputationEvents,
336
+ resetReputation,
337
+ getTier,
338
+ applyDecay,
339
+ isValidCategoryAction,
340
+ isValidCategory,
341
+ isValidAction,
342
+ ACTION_DELTAS,
343
+ BASE_SCORE,
344
+ MIN_SCORE,
345
+ MAX_SCORE,
346
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dupecom/botcha-cloudflare",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "BOTCHA for Cloudflare Workers - Prove you're a bot. Humans need not apply.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",