speclock 3.0.0 → 3.5.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,386 @@
1
+ /**
2
+ * SpecLock OAuth/OIDC SSO Framework (v3.5)
3
+ * Integrates with corporate identity providers (Okta, Azure AD, Auth0).
4
+ * OAuth 2.0 Authorization Code flow with PKCE.
5
+ * Token-based session management for HTTP server.
6
+ *
7
+ * Configuration via .speclock/sso.json or environment variables:
8
+ * SPECLOCK_SSO_ISSUER — OIDC issuer URL
9
+ * SPECLOCK_SSO_CLIENT_ID — OAuth client ID
10
+ * SPECLOCK_SSO_CLIENT_SECRET — OAuth client secret
11
+ * SPECLOCK_SSO_REDIRECT_URI — Callback URL (default: http://localhost:3000/auth/callback)
12
+ *
13
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import crypto from "crypto";
19
+
20
+ const SSO_CONFIG_FILE = "sso.json";
21
+ const TOKEN_STORE_FILE = "sso-tokens.json";
22
+
23
+ // --- Config ---
24
+
25
+ function ssoConfigPath(root) {
26
+ return path.join(root, ".speclock", SSO_CONFIG_FILE);
27
+ }
28
+
29
+ function tokenStorePath(root) {
30
+ return path.join(root, ".speclock", TOKEN_STORE_FILE);
31
+ }
32
+
33
+ /**
34
+ * Check if SSO is configured
35
+ */
36
+ export function isSSOEnabled(root) {
37
+ const config = getSSOConfig(root);
38
+ return !!(config.issuer && config.clientId);
39
+ }
40
+
41
+ /**
42
+ * Get SSO configuration from file or env vars
43
+ */
44
+ export function getSSOConfig(root) {
45
+ // Try file first
46
+ const p = ssoConfigPath(root);
47
+ let config = {};
48
+ if (fs.existsSync(p)) {
49
+ try {
50
+ config = JSON.parse(fs.readFileSync(p, "utf-8"));
51
+ } catch {
52
+ config = {};
53
+ }
54
+ }
55
+
56
+ // Env vars override file
57
+ return {
58
+ issuer: process.env.SPECLOCK_SSO_ISSUER || config.issuer || "",
59
+ clientId: process.env.SPECLOCK_SSO_CLIENT_ID || config.clientId || "",
60
+ clientSecret: process.env.SPECLOCK_SSO_CLIENT_SECRET || config.clientSecret || "",
61
+ redirectUri: process.env.SPECLOCK_SSO_REDIRECT_URI || config.redirectUri || "http://localhost:3000/auth/callback",
62
+ scopes: config.scopes || ["openid", "profile", "email"],
63
+ roleMapping: config.roleMapping || {
64
+ // Map OIDC groups/roles to SpecLock roles
65
+ // e.g., { "speclock-admin": "admin", "speclock-dev": "developer" }
66
+ },
67
+ defaultRole: config.defaultRole || "viewer",
68
+ sessionTtlMinutes: config.sessionTtlMinutes || 480, // 8 hours
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Save SSO configuration to file
74
+ */
75
+ export function saveSSOConfig(root, config) {
76
+ const p = ssoConfigPath(root);
77
+ fs.writeFileSync(p, JSON.stringify(config, null, 2));
78
+
79
+ // Ensure gitignored
80
+ const giPath = path.join(root, ".speclock", ".gitignore");
81
+ let giContent = "";
82
+ if (fs.existsSync(giPath)) {
83
+ giContent = fs.readFileSync(giPath, "utf-8");
84
+ }
85
+ for (const file of [SSO_CONFIG_FILE, TOKEN_STORE_FILE]) {
86
+ if (!giContent.includes(file)) {
87
+ const line = giContent.endsWith("\n") || giContent === "" ? file + "\n" : "\n" + file + "\n";
88
+ fs.appendFileSync(giPath, line);
89
+ giContent += line;
90
+ }
91
+ }
92
+
93
+ return { success: true };
94
+ }
95
+
96
+ // --- PKCE helpers ---
97
+
98
+ /**
99
+ * Generate PKCE code verifier (43-128 chars, [A-Za-z0-9-._~])
100
+ */
101
+ export function generateCodeVerifier() {
102
+ return crypto.randomBytes(32).toString("base64url");
103
+ }
104
+
105
+ /**
106
+ * Generate PKCE code challenge from verifier (S256)
107
+ */
108
+ export function generateCodeChallenge(verifier) {
109
+ return crypto.createHash("sha256").update(verifier).digest("base64url");
110
+ }
111
+
112
+ // --- OAuth 2.0 Authorization Code Flow ---
113
+
114
+ /**
115
+ * Generate the authorization URL for user redirect
116
+ */
117
+ export function getAuthorizationUrl(root, state) {
118
+ const config = getSSOConfig(root);
119
+ if (!config.issuer || !config.clientId) {
120
+ return { success: false, error: "SSO not configured. Set issuer and clientId." };
121
+ }
122
+
123
+ const codeVerifier = generateCodeVerifier();
124
+ const codeChallenge = generateCodeChallenge(codeVerifier);
125
+ const stateParam = state || crypto.randomBytes(16).toString("hex");
126
+
127
+ // Store PKCE verifier and state for callback validation
128
+ const pendingAuth = {
129
+ state: stateParam,
130
+ codeVerifier,
131
+ createdAt: new Date().toISOString(),
132
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 min
133
+ };
134
+
135
+ const storePath = tokenStorePath(root);
136
+ let store = {};
137
+ if (fs.existsSync(storePath)) {
138
+ try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { store = {}; }
139
+ }
140
+ if (!store.pending) store.pending = {};
141
+ store.pending[stateParam] = pendingAuth;
142
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
143
+
144
+ const params = new URLSearchParams({
145
+ response_type: "code",
146
+ client_id: config.clientId,
147
+ redirect_uri: config.redirectUri,
148
+ scope: config.scopes.join(" "),
149
+ state: stateParam,
150
+ code_challenge: codeChallenge,
151
+ code_challenge_method: "S256",
152
+ });
153
+
154
+ const authUrl = `${config.issuer}/authorize?${params.toString()}`;
155
+
156
+ return {
157
+ success: true,
158
+ url: authUrl,
159
+ state: stateParam,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Exchange authorization code for tokens (callback handler)
165
+ */
166
+ export async function handleCallback(root, code, state) {
167
+ const config = getSSOConfig(root);
168
+ const storePath = tokenStorePath(root);
169
+
170
+ let store = {};
171
+ if (fs.existsSync(storePath)) {
172
+ try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { store = {}; }
173
+ }
174
+
175
+ // Validate state
176
+ const pending = store.pending?.[state];
177
+ if (!pending) {
178
+ return { success: false, error: "Invalid or expired state parameter." };
179
+ }
180
+
181
+ // Check expiration
182
+ if (new Date(pending.expiresAt) < new Date()) {
183
+ delete store.pending[state];
184
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
185
+ return { success: false, error: "Authorization request expired." };
186
+ }
187
+
188
+ // Exchange code for tokens
189
+ const tokenEndpoint = `${config.issuer}/oauth/token`;
190
+ const body = new URLSearchParams({
191
+ grant_type: "authorization_code",
192
+ client_id: config.clientId,
193
+ client_secret: config.clientSecret,
194
+ code,
195
+ redirect_uri: config.redirectUri,
196
+ code_verifier: pending.codeVerifier,
197
+ });
198
+
199
+ try {
200
+ const response = await fetch(tokenEndpoint, {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
203
+ body: body.toString(),
204
+ signal: AbortSignal.timeout(10000),
205
+ });
206
+
207
+ if (!response.ok) {
208
+ const error = await response.text();
209
+ return { success: false, error: `Token exchange failed: ${error}` };
210
+ }
211
+
212
+ const tokens = await response.json();
213
+
214
+ // Parse ID token to get user info
215
+ const userInfo = parseIdToken(tokens.id_token);
216
+
217
+ // Map user's groups/roles to SpecLock role
218
+ const role = mapToSpecLockRole(config, userInfo);
219
+
220
+ // Create session
221
+ const sessionId = crypto.randomBytes(16).toString("hex");
222
+ const session = {
223
+ sessionId,
224
+ userId: userInfo.sub || userInfo.email || "unknown",
225
+ email: userInfo.email || "",
226
+ name: userInfo.name || "",
227
+ role,
228
+ accessToken: tokens.access_token,
229
+ refreshToken: tokens.refresh_token || null,
230
+ idToken: tokens.id_token || null,
231
+ expiresAt: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString(),
232
+ createdAt: new Date().toISOString(),
233
+ };
234
+
235
+ // Store session
236
+ if (!store.sessions) store.sessions = {};
237
+ store.sessions[sessionId] = session;
238
+
239
+ // Clean up pending auth
240
+ delete store.pending[state];
241
+
242
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
243
+
244
+ return {
245
+ success: true,
246
+ sessionId,
247
+ userId: session.userId,
248
+ email: session.email,
249
+ name: session.name,
250
+ role,
251
+ expiresAt: session.expiresAt,
252
+ };
253
+ } catch (err) {
254
+ return { success: false, error: `Token exchange error: ${err.message}` };
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Validate an SSO session token
260
+ */
261
+ export function validateSession(root, sessionId) {
262
+ if (!sessionId) {
263
+ return { valid: false, error: "Session ID required." };
264
+ }
265
+
266
+ const storePath = tokenStorePath(root);
267
+ if (!fs.existsSync(storePath)) {
268
+ return { valid: false, error: "No SSO sessions." };
269
+ }
270
+
271
+ let store;
272
+ try {
273
+ store = JSON.parse(fs.readFileSync(storePath, "utf-8"));
274
+ } catch {
275
+ return { valid: false, error: "Corrupted token store." };
276
+ }
277
+
278
+ const session = store.sessions?.[sessionId];
279
+ if (!session) {
280
+ return { valid: false, error: "Session not found." };
281
+ }
282
+
283
+ // Check expiration
284
+ if (new Date(session.expiresAt) < new Date()) {
285
+ delete store.sessions[sessionId];
286
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
287
+ return { valid: false, error: "Session expired." };
288
+ }
289
+
290
+ return {
291
+ valid: true,
292
+ userId: session.userId,
293
+ email: session.email,
294
+ name: session.name,
295
+ role: session.role,
296
+ expiresAt: session.expiresAt,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Revoke/logout an SSO session
302
+ */
303
+ export function revokeSession(root, sessionId) {
304
+ const storePath = tokenStorePath(root);
305
+ if (!fs.existsSync(storePath)) return { success: false, error: "No sessions." };
306
+
307
+ let store;
308
+ try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { return { success: false }; }
309
+
310
+ if (!store.sessions?.[sessionId]) {
311
+ return { success: false, error: "Session not found." };
312
+ }
313
+
314
+ delete store.sessions[sessionId];
315
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
316
+ return { success: true };
317
+ }
318
+
319
+ /**
320
+ * List active SSO sessions
321
+ */
322
+ export function listSessions(root) {
323
+ const storePath = tokenStorePath(root);
324
+ if (!fs.existsSync(storePath)) return { sessions: [], total: 0 };
325
+
326
+ let store;
327
+ try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { return { sessions: [], total: 0 }; }
328
+
329
+ const now = new Date();
330
+ const sessions = Object.values(store.sessions || {})
331
+ .filter(s => new Date(s.expiresAt) > now)
332
+ .map(s => ({
333
+ sessionId: s.sessionId,
334
+ userId: s.userId,
335
+ email: s.email,
336
+ name: s.name,
337
+ role: s.role,
338
+ expiresAt: s.expiresAt,
339
+ createdAt: s.createdAt,
340
+ }));
341
+
342
+ return { sessions, total: sessions.length };
343
+ }
344
+
345
+ // --- Token parsing ---
346
+
347
+ /**
348
+ * Parse JWT ID token payload (without verification — verification done by IdP)
349
+ */
350
+ function parseIdToken(idToken) {
351
+ if (!idToken) return {};
352
+ try {
353
+ const parts = idToken.split(".");
354
+ if (parts.length !== 3) return {};
355
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
356
+ return payload;
357
+ } catch {
358
+ return {};
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Map OIDC user info to a SpecLock role
364
+ */
365
+ function mapToSpecLockRole(config, userInfo) {
366
+ const roleMapping = config.roleMapping || {};
367
+
368
+ // Check user's groups
369
+ const groups = userInfo.groups || userInfo["cognito:groups"] || [];
370
+ for (const group of groups) {
371
+ if (roleMapping[group]) return roleMapping[group];
372
+ }
373
+
374
+ // Check user's roles claim
375
+ const roles = userInfo.roles || userInfo.realm_access?.roles || [];
376
+ for (const role of roles) {
377
+ if (roleMapping[role]) return roleMapping[role];
378
+ }
379
+
380
+ // Check email domain mapping
381
+ if (userInfo.email && roleMapping["@" + userInfo.email.split("@")[1]]) {
382
+ return roleMapping["@" + userInfo.email.split("@")[1]];
383
+ }
384
+
385
+ return config.defaultRole || "viewer";
386
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * SpecLock Telemetry & Analytics (v3.5)
3
+ * Opt-in anonymous usage analytics for product improvement.
4
+ *
5
+ * DISABLED by default. Enable via SPECLOCK_TELEMETRY=true env var.
6
+ * NEVER tracks: lock content, project names, file paths, PII.
7
+ * ONLY tracks: tool usage counts, conflict rates, response times, feature adoption.
8
+ *
9
+ * Data stored locally in .speclock/telemetry.json.
10
+ * Optional remote endpoint via SPECLOCK_TELEMETRY_ENDPOINT env var.
11
+ *
12
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
13
+ */
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+
18
+ const TELEMETRY_FILE = "telemetry.json";
19
+ const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
20
+ const MAX_EVENTS_BUFFER = 500;
21
+
22
+ // --- Telemetry state ---
23
+
24
+ let _enabled = null;
25
+ let _buffer = [];
26
+ let _flushTimer = null;
27
+
28
+ /**
29
+ * Check if telemetry is enabled (opt-in only)
30
+ */
31
+ export function isTelemetryEnabled() {
32
+ if (_enabled !== null) return _enabled;
33
+ _enabled = process.env.SPECLOCK_TELEMETRY === "true";
34
+ return _enabled;
35
+ }
36
+
37
+ /**
38
+ * Reset telemetry state (for testing)
39
+ */
40
+ export function resetTelemetry() {
41
+ _enabled = null;
42
+ _buffer = [];
43
+ if (_flushTimer) {
44
+ clearInterval(_flushTimer);
45
+ _flushTimer = null;
46
+ }
47
+ }
48
+
49
+ // --- Local telemetry store ---
50
+
51
+ function telemetryPath(root) {
52
+ return path.join(root, ".speclock", TELEMETRY_FILE);
53
+ }
54
+
55
+ function readTelemetryStore(root) {
56
+ const p = telemetryPath(root);
57
+ if (!fs.existsSync(p)) {
58
+ return createEmptyStore();
59
+ }
60
+ try {
61
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
62
+ } catch {
63
+ return createEmptyStore();
64
+ }
65
+ }
66
+
67
+ function writeTelemetryStore(root, store) {
68
+ const p = telemetryPath(root);
69
+ fs.writeFileSync(p, JSON.stringify(store, null, 2));
70
+ }
71
+
72
+ function createEmptyStore() {
73
+ return {
74
+ version: "1.0",
75
+ instanceId: generateInstanceId(),
76
+ createdAt: new Date().toISOString(),
77
+ updatedAt: new Date().toISOString(),
78
+ toolUsage: {},
79
+ conflicts: { total: 0, blocked: 0, advisory: 0 },
80
+ features: {},
81
+ sessions: { total: 0, tools: {} },
82
+ responseTimes: { samples: [], avgMs: 0 },
83
+ daily: {},
84
+ };
85
+ }
86
+
87
+ function generateInstanceId() {
88
+ // Anonymous instance ID — no PII, just random hex
89
+ const bytes = new Uint8Array(8);
90
+ for (let i = 0; i < 8; i++) bytes[i] = Math.floor(Math.random() * 256);
91
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
92
+ }
93
+
94
+ // --- Tracking functions ---
95
+
96
+ /**
97
+ * Track a tool invocation
98
+ */
99
+ export function trackToolUsage(root, toolName, durationMs) {
100
+ if (!isTelemetryEnabled()) return;
101
+
102
+ const store = readTelemetryStore(root);
103
+
104
+ // Tool usage count
105
+ if (!store.toolUsage[toolName]) {
106
+ store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
107
+ }
108
+ store.toolUsage[toolName].count++;
109
+ store.toolUsage[toolName].totalMs += (durationMs || 0);
110
+ store.toolUsage[toolName].avgMs = Math.round(
111
+ store.toolUsage[toolName].totalMs / store.toolUsage[toolName].count
112
+ );
113
+
114
+ // Response time sampling (keep last 100)
115
+ if (durationMs) {
116
+ store.responseTimes.samples.push(durationMs);
117
+ if (store.responseTimes.samples.length > 100) {
118
+ store.responseTimes.samples = store.responseTimes.samples.slice(-100);
119
+ }
120
+ store.responseTimes.avgMs = Math.round(
121
+ store.responseTimes.samples.reduce((a, b) => a + b, 0) / store.responseTimes.samples.length
122
+ );
123
+ }
124
+
125
+ // Daily counter
126
+ const today = new Date().toISOString().slice(0, 10);
127
+ if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
128
+ store.daily[today].calls++;
129
+
130
+ // Trim daily entries older than 30 days
131
+ const cutoff = new Date();
132
+ cutoff.setDate(cutoff.getDate() - 30);
133
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
134
+ for (const key of Object.keys(store.daily)) {
135
+ if (key < cutoffStr) delete store.daily[key];
136
+ }
137
+
138
+ store.updatedAt = new Date().toISOString();
139
+ writeTelemetryStore(root, store);
140
+ }
141
+
142
+ /**
143
+ * Track a conflict check result
144
+ */
145
+ export function trackConflict(root, hasConflict, blocked) {
146
+ if (!isTelemetryEnabled()) return;
147
+
148
+ const store = readTelemetryStore(root);
149
+ store.conflicts.total++;
150
+ if (blocked) {
151
+ store.conflicts.blocked++;
152
+ } else if (hasConflict) {
153
+ store.conflicts.advisory++;
154
+ }
155
+
156
+ const today = new Date().toISOString().slice(0, 10);
157
+ if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
158
+ if (hasConflict) store.daily[today].conflicts++;
159
+
160
+ store.updatedAt = new Date().toISOString();
161
+ writeTelemetryStore(root, store);
162
+ }
163
+
164
+ /**
165
+ * Track feature adoption (which features are being used)
166
+ */
167
+ export function trackFeature(root, featureName) {
168
+ if (!isTelemetryEnabled()) return;
169
+
170
+ const store = readTelemetryStore(root);
171
+ if (!store.features[featureName]) {
172
+ store.features[featureName] = { firstUsed: new Date().toISOString(), count: 0 };
173
+ }
174
+ store.features[featureName].count++;
175
+ store.features[featureName].lastUsed = new Date().toISOString();
176
+
177
+ store.updatedAt = new Date().toISOString();
178
+ writeTelemetryStore(root, store);
179
+ }
180
+
181
+ /**
182
+ * Track session start
183
+ */
184
+ export function trackSession(root, toolName) {
185
+ if (!isTelemetryEnabled()) return;
186
+
187
+ const store = readTelemetryStore(root);
188
+ store.sessions.total++;
189
+ if (!store.sessions.tools[toolName]) store.sessions.tools[toolName] = 0;
190
+ store.sessions.tools[toolName]++;
191
+
192
+ store.updatedAt = new Date().toISOString();
193
+ writeTelemetryStore(root, store);
194
+ }
195
+
196
+ // --- Analytics / Reporting ---
197
+
198
+ /**
199
+ * Get telemetry summary for dashboard display
200
+ */
201
+ export function getTelemetrySummary(root) {
202
+ if (!isTelemetryEnabled()) {
203
+ return { enabled: false, message: "Telemetry is disabled. Set SPECLOCK_TELEMETRY=true to enable." };
204
+ }
205
+
206
+ const store = readTelemetryStore(root);
207
+
208
+ // Top tools by usage
209
+ const topTools = Object.entries(store.toolUsage)
210
+ .sort(([, a], [, b]) => b.count - a.count)
211
+ .slice(0, 10)
212
+ .map(([name, data]) => ({ name, ...data }));
213
+
214
+ // Daily trend (last 7 days)
215
+ const days = [];
216
+ for (let i = 6; i >= 0; i--) {
217
+ const d = new Date();
218
+ d.setDate(d.getDate() - i);
219
+ const key = d.toISOString().slice(0, 10);
220
+ days.push({ date: key, ...(store.daily[key] || { calls: 0, conflicts: 0 }) });
221
+ }
222
+
223
+ // Feature adoption
224
+ const features = Object.entries(store.features)
225
+ .sort(([, a], [, b]) => b.count - a.count)
226
+ .map(([name, data]) => ({ name, ...data }));
227
+
228
+ return {
229
+ enabled: true,
230
+ instanceId: store.instanceId,
231
+ updatedAt: store.updatedAt,
232
+ totalCalls: Object.values(store.toolUsage).reduce((sum, t) => sum + t.count, 0),
233
+ avgResponseMs: store.responseTimes.avgMs,
234
+ conflicts: store.conflicts,
235
+ sessions: store.sessions,
236
+ topTools,
237
+ dailyTrend: days,
238
+ features,
239
+ };
240
+ }
241
+
242
+ // --- Remote telemetry (optional) ---
243
+
244
+ /**
245
+ * Flush telemetry to remote endpoint if configured.
246
+ * Only sends anonymized aggregate data — never lock content or PII.
247
+ */
248
+ export async function flushToRemote(root) {
249
+ if (!isTelemetryEnabled()) return { sent: false, reason: "disabled" };
250
+
251
+ const endpoint = process.env.SPECLOCK_TELEMETRY_ENDPOINT;
252
+ if (!endpoint) return { sent: false, reason: "no endpoint configured" };
253
+
254
+ const summary = getTelemetrySummary(root);
255
+ if (!summary.enabled) return { sent: false, reason: "disabled" };
256
+
257
+ // Build anonymized payload
258
+ const payload = {
259
+ instanceId: summary.instanceId,
260
+ version: "3.5.0",
261
+ totalCalls: summary.totalCalls,
262
+ avgResponseMs: summary.avgResponseMs,
263
+ conflicts: summary.conflicts,
264
+ sessions: summary.sessions,
265
+ topTools: summary.topTools.map(t => ({ name: t.name, count: t.count })),
266
+ features: summary.features.map(f => ({ name: f.name, count: f.count })),
267
+ timestamp: new Date().toISOString(),
268
+ };
269
+
270
+ try {
271
+ const response = await fetch(endpoint, {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify(payload),
275
+ signal: AbortSignal.timeout(5000),
276
+ });
277
+ return { sent: true, status: response.status };
278
+ } catch {
279
+ return { sent: false, reason: "network error" };
280
+ }
281
+ }