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.
- package/README.md +76 -4
- package/package.json +10 -3
- package/src/cli/index.js +174 -1
- package/src/core/compliance.js +1 -1
- package/src/core/engine.js +38 -0
- package/src/core/policy.js +719 -0
- package/src/core/sso.js +386 -0
- package/src/core/telemetry.js +281 -0
- package/src/dashboard/index.html +338 -0
- package/src/mcp/http-server.js +156 -1
- package/src/mcp/server.js +149 -1
package/src/core/sso.js
ADDED
|
@@ -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
|
+
}
|