speclock 1.7.0 → 2.1.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,221 @@
1
+ /**
2
+ * SpecLock Freemium License System
3
+ * Three tiers: Free, Pro ($19/mo), Enterprise ($99/mo).
4
+ * Graceful degradation — free tier always works.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import crypto from "crypto";
12
+ import { speclockDir, readBrain } from "./storage.js";
13
+
14
+ // Tier definitions
15
+ const TIERS = {
16
+ free: {
17
+ name: "Free",
18
+ maxLocks: 10,
19
+ maxDecisions: 5,
20
+ maxEvents: 500,
21
+ features: ["basic_conflict_detection", "context_pack", "session_tracking", "cli", "mcp"],
22
+ },
23
+ pro: {
24
+ name: "Pro",
25
+ maxLocks: Infinity,
26
+ maxDecisions: Infinity,
27
+ maxEvents: Infinity,
28
+ features: [
29
+ "basic_conflict_detection", "context_pack", "session_tracking", "cli", "mcp",
30
+ "llm_conflict_detection", "hmac_audit_chain", "compliance_exports",
31
+ "drift_detection", "templates", "github_actions",
32
+ ],
33
+ },
34
+ enterprise: {
35
+ name: "Enterprise",
36
+ maxLocks: Infinity,
37
+ maxDecisions: Infinity,
38
+ maxEvents: Infinity,
39
+ features: [
40
+ "basic_conflict_detection", "context_pack", "session_tracking", "cli", "mcp",
41
+ "llm_conflict_detection", "hmac_audit_chain", "compliance_exports",
42
+ "drift_detection", "templates", "github_actions",
43
+ "rbac", "encrypted_storage", "sso", "hard_enforcement",
44
+ "semantic_precommit", "multi_project", "priority_support",
45
+ ],
46
+ },
47
+ };
48
+
49
+ const LICENSE_FILE = ".license";
50
+
51
+ /**
52
+ * Get the current license key from env or file.
53
+ */
54
+ function getLicenseKey(root) {
55
+ // 1. Environment variable
56
+ if (process.env.SPECLOCK_LICENSE_KEY) {
57
+ return process.env.SPECLOCK_LICENSE_KEY;
58
+ }
59
+
60
+ // 2. License file in .speclock/
61
+ if (root) {
62
+ const licensePath = path.join(speclockDir(root), LICENSE_FILE);
63
+ if (fs.existsSync(licensePath)) {
64
+ return fs.readFileSync(licensePath, "utf8").trim();
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Decode a license key to extract tier and expiry.
73
+ * License format: base64(JSON({ tier, expiresAt, signature }))
74
+ * For now, a simple validation — full crypto verification in v3.0.
75
+ */
76
+ function decodeLicense(key) {
77
+ if (!key) return null;
78
+
79
+ try {
80
+ const decoded = JSON.parse(Buffer.from(key, "base64").toString("utf8"));
81
+ if (!decoded.tier || !decoded.expiresAt) return null;
82
+
83
+ // Check expiry
84
+ if (new Date(decoded.expiresAt) < new Date()) {
85
+ return { tier: "free", expired: true, originalTier: decoded.tier };
86
+ }
87
+
88
+ // Validate tier name
89
+ if (!TIERS[decoded.tier]) return null;
90
+
91
+ return { tier: decoded.tier, expiresAt: decoded.expiresAt, expired: false };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get the current tier for this project.
99
+ */
100
+ export function getTier(root) {
101
+ const key = getLicenseKey(root);
102
+ if (!key) return "free";
103
+
104
+ const license = decodeLicense(key);
105
+ if (!license || license.expired) return "free";
106
+
107
+ return license.tier;
108
+ }
109
+
110
+ /**
111
+ * Get the limits for the current tier.
112
+ */
113
+ export function getLimits(root) {
114
+ const tier = getTier(root);
115
+ return { tier, ...TIERS[tier] };
116
+ }
117
+
118
+ /**
119
+ * Check if a specific feature is available in the current tier.
120
+ * Returns { allowed: bool, tier: string, requiredTier: string|null }
121
+ */
122
+ export function checkFeature(root, featureName) {
123
+ const tier = getTier(root);
124
+ const tierConfig = TIERS[tier];
125
+
126
+ if (tierConfig.features.includes(featureName)) {
127
+ return { allowed: true, tier, requiredTier: null };
128
+ }
129
+
130
+ // Find which tier has this feature
131
+ const requiredTier = Object.entries(TIERS).find(
132
+ ([_, config]) => config.features.includes(featureName)
133
+ );
134
+
135
+ return {
136
+ allowed: false,
137
+ tier,
138
+ requiredTier: requiredTier ? requiredTier[0] : null,
139
+ message: requiredTier
140
+ ? `Feature "${featureName}" requires ${requiredTier[1].name} tier. Current: ${tierConfig.name}. Upgrade at https://speclock.dev/pricing`
141
+ : `Unknown feature: ${featureName}`,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Check if the current project is within its tier limits.
147
+ * Returns { withinLimits: bool, warnings: string[] }
148
+ */
149
+ export function checkLimits(root) {
150
+ const tier = getTier(root);
151
+ const limits = TIERS[tier];
152
+ const brain = readBrain(root);
153
+ const warnings = [];
154
+
155
+ if (!brain) return { withinLimits: true, warnings: [], tier };
156
+
157
+ const activeLocks = (brain.specLock?.items || []).filter((l) => l.active).length;
158
+ const decisions = (brain.decisions || []).length;
159
+ const eventCount = brain.events?.count || 0;
160
+
161
+ if (activeLocks >= limits.maxLocks) {
162
+ warnings.push(
163
+ `Lock limit reached (${activeLocks}/${limits.maxLocks}). Upgrade to Pro for unlimited locks.`
164
+ );
165
+ }
166
+
167
+ if (decisions >= limits.maxDecisions) {
168
+ warnings.push(
169
+ `Decision limit reached (${decisions}/${limits.maxDecisions}). Upgrade to Pro for unlimited decisions.`
170
+ );
171
+ }
172
+
173
+ if (eventCount >= limits.maxEvents) {
174
+ warnings.push(
175
+ `Event limit reached (${eventCount}/${limits.maxEvents}). Upgrade to Pro for unlimited events.`
176
+ );
177
+ }
178
+
179
+ return {
180
+ withinLimits: warnings.length === 0,
181
+ warnings,
182
+ tier,
183
+ usage: {
184
+ locks: { current: activeLocks, max: limits.maxLocks },
185
+ decisions: { current: decisions, max: limits.maxDecisions },
186
+ events: { current: eventCount, max: limits.maxEvents },
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Generate a license key (for testing / admin use).
193
+ * In production, keys would be generated server-side.
194
+ */
195
+ export function generateLicenseKey(tier, daysValid = 30) {
196
+ if (!TIERS[tier]) throw new Error(`Invalid tier: ${tier}`);
197
+
198
+ const expiresAt = new Date(Date.now() + daysValid * 24 * 60 * 60 * 1000).toISOString();
199
+ const payload = { tier, expiresAt, issuedAt: new Date().toISOString() };
200
+
201
+ return Buffer.from(JSON.stringify(payload)).toString("base64");
202
+ }
203
+
204
+ /**
205
+ * Get license info for display.
206
+ */
207
+ export function getLicenseInfo(root) {
208
+ const key = getLicenseKey(root);
209
+ const tier = getTier(root);
210
+ const limits = checkLimits(root);
211
+ const license = key ? decodeLicense(key) : null;
212
+
213
+ return {
214
+ tier: TIERS[tier].name,
215
+ tierKey: tier,
216
+ expiresAt: license?.expiresAt || null,
217
+ expired: license?.expired || false,
218
+ ...limits,
219
+ features: TIERS[tier].features,
220
+ };
221
+ }
@@ -0,0 +1,239 @@
1
+ // ===================================================================
2
+ // SpecLock LLM-Powered Conflict Checker (Optional)
3
+ // Uses OpenAI or Anthropic APIs for enterprise-grade detection.
4
+ // Zero mandatory dependencies — uses built-in fetch().
5
+ // Falls back gracefully if no API key is configured.
6
+ // ===================================================================
7
+
8
+ import { readBrain } from "./storage.js";
9
+
10
+ // --- In-memory LRU cache ---
11
+ const CACHE_MAX = 200;
12
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
13
+ const cache = new Map();
14
+
15
+ function cacheKey(action, locks) {
16
+ return `${action}::${locks.map(l => l.text).sort().join("|")}`;
17
+ }
18
+
19
+ function cacheGet(key) {
20
+ const entry = cache.get(key);
21
+ if (!entry) return null;
22
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
23
+ cache.delete(key);
24
+ return null;
25
+ }
26
+ return entry.value;
27
+ }
28
+
29
+ function cacheSet(key, value) {
30
+ if (cache.size >= CACHE_MAX) {
31
+ // Evict oldest entry
32
+ const oldest = cache.keys().next().value;
33
+ cache.delete(oldest);
34
+ }
35
+ cache.set(key, { value, ts: Date.now() });
36
+ }
37
+
38
+ // --- Configuration ---
39
+
40
+ function getConfig(root) {
41
+ // Priority: env var > brain.json config
42
+ const apiKey = process.env.SPECLOCK_LLM_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY;
43
+ const provider = process.env.SPECLOCK_LLM_PROVIDER || "openai"; // "openai" or "anthropic"
44
+
45
+ if (apiKey) {
46
+ return { apiKey, provider };
47
+ }
48
+
49
+ // Check brain.json for LLM config
50
+ try {
51
+ const brain = readBrain(root);
52
+ if (brain?.facts?.llm) {
53
+ return {
54
+ apiKey: brain.facts.llm.apiKey,
55
+ provider: brain.facts.llm.provider || "openai",
56
+ };
57
+ }
58
+ } catch (_) {}
59
+
60
+ return null;
61
+ }
62
+
63
+ // --- System prompt ---
64
+
65
+ const SYSTEM_PROMPT = `You are a security constraint checker for SpecLock, an AI constraint engine.
66
+
67
+ Your job: determine if a proposed action conflicts with any active SpecLock constraints (locks).
68
+
69
+ Rules:
70
+ 1. A lock like "Never X" means the action MUST NOT do X, regardless of phrasing.
71
+ 2. Watch for EUPHEMISMS: "clean up data" = delete, "streamline" = remove, "sunset" = deprecate/remove.
72
+ 3. Watch for TECHNICAL JARGON: "truncate table" = delete records, "flash firmware" = overwrite, "bridge segments" = connect.
73
+ 4. Watch for TEMPORAL SOFTENERS: "temporarily disable" is still disabling. "Just for testing" is still doing it.
74
+ 5. Watch for CONTEXT DILUTION: "update UI and also delete patient records" — the second part conflicts even if buried.
75
+ 6. POSITIVE actions do NOT conflict: "Enable audit logging" does NOT conflict with "Never disable audit logging".
76
+ 7. Read-only actions do NOT conflict: "View patient records" does NOT conflict with "Never delete patient records".
77
+
78
+ Respond with ONLY valid JSON (no markdown, no explanation):
79
+ {
80
+ "hasConflict": true/false,
81
+ "conflicts": [
82
+ {
83
+ "lockText": "the lock text",
84
+ "confidence": 0-100,
85
+ "level": "HIGH/MEDIUM/LOW",
86
+ "reasons": ["reason1", "reason2"]
87
+ }
88
+ ],
89
+ "analysis": "one-line summary"
90
+ }`;
91
+
92
+ // --- API callers ---
93
+
94
+ async function callOpenAI(apiKey, userPrompt) {
95
+ const resp = await fetch("https://api.openai.com/v1/chat/completions", {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ "Authorization": `Bearer ${apiKey}`,
100
+ },
101
+ body: JSON.stringify({
102
+ model: "gpt-4o-mini",
103
+ messages: [
104
+ { role: "system", content: SYSTEM_PROMPT },
105
+ { role: "user", content: userPrompt },
106
+ ],
107
+ temperature: 0.1,
108
+ max_tokens: 1000,
109
+ }),
110
+ });
111
+
112
+ if (!resp.ok) return null;
113
+ const data = await resp.json();
114
+ const content = data.choices?.[0]?.message?.content;
115
+ if (!content) return null;
116
+
117
+ try {
118
+ return JSON.parse(content);
119
+ } catch (_) {
120
+ // Try to extract JSON from markdown code block
121
+ const match = content.match(/```(?:json)?\s*([\s\S]*?)```/);
122
+ if (match) return JSON.parse(match[1]);
123
+ return null;
124
+ }
125
+ }
126
+
127
+ async function callAnthropic(apiKey, userPrompt) {
128
+ const resp = await fetch("https://api.anthropic.com/v1/messages", {
129
+ method: "POST",
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ "x-api-key": apiKey,
133
+ "anthropic-version": "2023-06-01",
134
+ },
135
+ body: JSON.stringify({
136
+ model: "claude-sonnet-4-20250514",
137
+ max_tokens: 1000,
138
+ system: SYSTEM_PROMPT,
139
+ messages: [
140
+ { role: "user", content: userPrompt },
141
+ ],
142
+ }),
143
+ });
144
+
145
+ if (!resp.ok) return null;
146
+ const data = await resp.json();
147
+ const content = data.content?.[0]?.text;
148
+ if (!content) return null;
149
+
150
+ try {
151
+ return JSON.parse(content);
152
+ } catch (_) {
153
+ const match = content.match(/```(?:json)?\s*([\s\S]*?)```/);
154
+ if (match) return JSON.parse(match[1]);
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // --- Main export ---
160
+
161
+ /**
162
+ * Check conflicts using LLM. Returns null on any failure (caller should fall back to heuristic).
163
+ * @param {string} root - Project root path
164
+ * @param {string} proposedAction - The action to check
165
+ * @param {Array} [activeLocks] - Optional pre-fetched locks
166
+ * @returns {Promise<Object|null>} - Same shape as checkConflict() return, or null
167
+ */
168
+ export async function llmCheckConflict(root, proposedAction, activeLocks) {
169
+ const config = getConfig(root);
170
+ if (!config) return null;
171
+
172
+ // Get active locks if not provided
173
+ if (!activeLocks) {
174
+ try {
175
+ const brain = readBrain(root);
176
+ activeLocks = brain?.specLock?.items?.filter(l => l.active !== false) || [];
177
+ } catch (_) {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ if (activeLocks.length === 0) {
183
+ return {
184
+ hasConflict: false,
185
+ conflictingLocks: [],
186
+ analysis: "No active locks. No constraints to check against.",
187
+ };
188
+ }
189
+
190
+ // Check cache
191
+ const key = cacheKey(proposedAction, activeLocks);
192
+ const cached = cacheGet(key);
193
+ if (cached) return cached;
194
+
195
+ // Build user prompt
196
+ const lockList = activeLocks.map((l, i) => `${i + 1}. "${l.text}"`).join("\n");
197
+ const userPrompt = `Active SpecLocks:\n${lockList}\n\nProposed Action: "${proposedAction}"\n\nDoes this action conflict with any lock?`;
198
+
199
+ // Call LLM
200
+ let llmResult = null;
201
+ try {
202
+ if (config.provider === "anthropic") {
203
+ llmResult = await callAnthropic(config.apiKey, userPrompt);
204
+ } else {
205
+ llmResult = await callOpenAI(config.apiKey, userPrompt);
206
+ }
207
+ } catch (_) {
208
+ return null;
209
+ }
210
+
211
+ if (!llmResult) return null;
212
+
213
+ // Convert LLM response to checkConflict format
214
+ const conflicting = (llmResult.conflicts || [])
215
+ .filter(c => c.confidence >= 25)
216
+ .map(c => {
217
+ // Find matching lock
218
+ const lock = activeLocks.find(l => l.text === c.lockText) || { id: "unknown", text: c.lockText };
219
+ return {
220
+ id: lock.id,
221
+ text: c.lockText,
222
+ matchedKeywords: [],
223
+ confidence: c.confidence,
224
+ level: c.level || (c.confidence >= 70 ? "HIGH" : c.confidence >= 40 ? "MEDIUM" : "LOW"),
225
+ reasons: c.reasons || [],
226
+ };
227
+ });
228
+
229
+ const result = {
230
+ hasConflict: conflicting.length > 0,
231
+ conflictingLocks: conflicting,
232
+ analysis: llmResult.analysis || (conflicting.length > 0
233
+ ? `LLM detected ${conflicting.length} conflict(s). Review before proceeding.`
234
+ : `LLM checked against ${activeLocks.length} lock(s). No conflicts detected.`),
235
+ };
236
+
237
+ cacheSet(key, result);
238
+ return result;
239
+ }