guardrail-core 1.0.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 (189) hide show
  1. package/dist/__tests__/autopilot.test.d.ts +7 -0
  2. package/dist/__tests__/autopilot.test.d.ts.map +1 -0
  3. package/dist/__tests__/autopilot.test.js +156 -0
  4. package/dist/__tests__/tier-config.test.d.ts +9 -0
  5. package/dist/__tests__/tier-config.test.d.ts.map +1 -0
  6. package/dist/__tests__/tier-config.test.js +230 -0
  7. package/dist/__tests__/utils/hash-inline.test.d.ts +2 -0
  8. package/dist/__tests__/utils/hash-inline.test.d.ts.map +1 -0
  9. package/dist/__tests__/utils/hash-inline.test.js +62 -0
  10. package/dist/__tests__/utils/hash.test.d.ts +3 -0
  11. package/dist/__tests__/utils/hash.test.d.ts.map +1 -0
  12. package/dist/__tests__/utils/hash.test.js +95 -0
  13. package/dist/__tests__/utils/simple.test.d.ts +1 -0
  14. package/dist/__tests__/utils/simple.test.d.ts.map +1 -0
  15. package/dist/__tests__/utils/simple.test.js +10 -0
  16. package/dist/__tests__/utils/utils-simple.test.d.ts +1 -0
  17. package/dist/__tests__/utils/utils-simple.test.d.ts.map +1 -0
  18. package/dist/__tests__/utils/utils-simple.test.js +6 -0
  19. package/dist/__tests__/utils/utils.test.d.ts +15 -0
  20. package/dist/__tests__/utils/utils.test.d.ts.map +1 -0
  21. package/dist/__tests__/utils/utils.test.js +172 -0
  22. package/dist/autopilot/autopilot-runner.d.ts +33 -0
  23. package/dist/autopilot/autopilot-runner.d.ts.map +1 -0
  24. package/dist/autopilot/autopilot-runner.js +479 -0
  25. package/dist/autopilot/index.d.ts +6 -0
  26. package/dist/autopilot/index.d.ts.map +1 -0
  27. package/dist/autopilot/index.js +25 -0
  28. package/dist/autopilot/types.d.ts +102 -0
  29. package/dist/autopilot/types.d.ts.map +1 -0
  30. package/dist/autopilot/types.js +18 -0
  31. package/dist/cache/index.d.ts +7 -0
  32. package/dist/cache/index.d.ts.map +1 -0
  33. package/dist/cache/index.js +22 -0
  34. package/dist/cache/redis-cache.d.ts +145 -0
  35. package/dist/cache/redis-cache.d.ts.map +1 -0
  36. package/dist/cache/redis-cache.js +459 -0
  37. package/dist/ci/github-actions.d.ts +77 -0
  38. package/dist/ci/github-actions.d.ts.map +1 -0
  39. package/dist/ci/github-actions.js +277 -0
  40. package/dist/ci/index.d.ts +12 -0
  41. package/dist/ci/index.d.ts.map +1 -0
  42. package/dist/ci/index.js +27 -0
  43. package/dist/ci/pre-commit.d.ts +65 -0
  44. package/dist/ci/pre-commit.d.ts.map +1 -0
  45. package/dist/ci/pre-commit.js +286 -0
  46. package/dist/entitlements.d.ts +149 -0
  47. package/dist/entitlements.d.ts.map +1 -0
  48. package/dist/entitlements.js +464 -0
  49. package/dist/env.d.ts +113 -0
  50. package/dist/env.d.ts.map +1 -0
  51. package/dist/env.js +204 -0
  52. package/dist/fix-packs/__tests__/generate-fix-packs.test.d.ts +7 -0
  53. package/dist/fix-packs/__tests__/generate-fix-packs.test.d.ts.map +1 -0
  54. package/dist/fix-packs/__tests__/generate-fix-packs.test.js +250 -0
  55. package/dist/fix-packs/generate-fix-packs.d.ts +15 -0
  56. package/dist/fix-packs/generate-fix-packs.d.ts.map +1 -0
  57. package/dist/fix-packs/generate-fix-packs.js +505 -0
  58. package/dist/fix-packs/index.d.ts +8 -0
  59. package/dist/fix-packs/index.d.ts.map +1 -0
  60. package/dist/fix-packs/index.js +23 -0
  61. package/dist/fix-packs/types.d.ts +113 -0
  62. package/dist/fix-packs/types.d.ts.map +1 -0
  63. package/dist/fix-packs/types.js +71 -0
  64. package/dist/index.d.ts +13 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +28 -0
  67. package/dist/metrics/prometheus.d.ts +99 -0
  68. package/dist/metrics/prometheus.d.ts.map +1 -0
  69. package/dist/metrics/prometheus.js +306 -0
  70. package/dist/quota-ledger.d.ts +119 -0
  71. package/dist/quota-ledger.d.ts.map +1 -0
  72. package/dist/quota-ledger.js +462 -0
  73. package/dist/rbac/__tests__/permissions.test.d.ts +8 -0
  74. package/dist/rbac/__tests__/permissions.test.d.ts.map +1 -0
  75. package/dist/rbac/__tests__/permissions.test.js +350 -0
  76. package/dist/rbac/index.d.ts +9 -0
  77. package/dist/rbac/index.d.ts.map +1 -0
  78. package/dist/rbac/index.js +32 -0
  79. package/dist/rbac/permissions.d.ts +71 -0
  80. package/dist/rbac/permissions.d.ts.map +1 -0
  81. package/dist/rbac/permissions.js +247 -0
  82. package/dist/rbac/types.d.ts +69 -0
  83. package/dist/rbac/types.d.ts.map +1 -0
  84. package/dist/rbac/types.js +213 -0
  85. package/dist/tier-config.d.ts +203 -0
  86. package/dist/tier-config.d.ts.map +1 -0
  87. package/dist/tier-config.js +675 -0
  88. package/dist/types.d.ts +365 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/types.js +5 -0
  91. package/dist/utils.d.ts +36 -0
  92. package/dist/utils.d.ts.map +1 -0
  93. package/dist/utils.js +127 -0
  94. package/dist/verified-autofix/__tests__/format-validator.test.d.ts +11 -0
  95. package/dist/verified-autofix/__tests__/format-validator.test.d.ts.map +1 -0
  96. package/dist/verified-autofix/__tests__/format-validator.test.js +285 -0
  97. package/dist/verified-autofix/__tests__/pipeline.test.d.ts +11 -0
  98. package/dist/verified-autofix/__tests__/pipeline.test.d.ts.map +1 -0
  99. package/dist/verified-autofix/__tests__/pipeline.test.js +389 -0
  100. package/dist/verified-autofix/__tests__/repo-fingerprint.test.d.ts +11 -0
  101. package/dist/verified-autofix/__tests__/repo-fingerprint.test.d.ts.map +1 -0
  102. package/dist/verified-autofix/__tests__/repo-fingerprint.test.js +236 -0
  103. package/dist/verified-autofix/__tests__/workspace.test.d.ts +11 -0
  104. package/dist/verified-autofix/__tests__/workspace.test.d.ts.map +1 -0
  105. package/dist/verified-autofix/__tests__/workspace.test.js +314 -0
  106. package/dist/verified-autofix/format-validator.d.ts +101 -0
  107. package/dist/verified-autofix/format-validator.d.ts.map +1 -0
  108. package/dist/verified-autofix/format-validator.js +446 -0
  109. package/dist/verified-autofix/index.d.ts +14 -0
  110. package/dist/verified-autofix/index.d.ts.map +1 -0
  111. package/dist/verified-autofix/index.js +39 -0
  112. package/dist/verified-autofix/pipeline.d.ts +68 -0
  113. package/dist/verified-autofix/pipeline.d.ts.map +1 -0
  114. package/dist/verified-autofix/pipeline.js +330 -0
  115. package/dist/verified-autofix/repo-fingerprint.d.ts +56 -0
  116. package/dist/verified-autofix/repo-fingerprint.d.ts.map +1 -0
  117. package/dist/verified-autofix/repo-fingerprint.js +396 -0
  118. package/dist/verified-autofix/workspace.d.ts +83 -0
  119. package/dist/verified-autofix/workspace.d.ts.map +1 -0
  120. package/dist/verified-autofix/workspace.js +454 -0
  121. package/dist/verified-autofix.d.ts +182 -0
  122. package/dist/verified-autofix.d.ts.map +1 -0
  123. package/dist/verified-autofix.js +1021 -0
  124. package/dist/visualization/dependency-graph.d.ts +79 -0
  125. package/dist/visualization/dependency-graph.d.ts.map +1 -0
  126. package/dist/visualization/dependency-graph.js +399 -0
  127. package/dist/visualization/index.d.ts +5 -0
  128. package/dist/visualization/index.d.ts.map +1 -0
  129. package/dist/visualization/index.js +20 -0
  130. package/package.json +29 -0
  131. package/src/__tests__/autopilot.test.ts +196 -0
  132. package/src/__tests__/tier-config.test.ts +289 -0
  133. package/src/__tests__/utils/hash-inline.test.ts +76 -0
  134. package/src/__tests__/utils/hash.test.ts +119 -0
  135. package/src/__tests__/utils/simple.test.ts +10 -0
  136. package/src/__tests__/utils/utils-simple.test.ts +5 -0
  137. package/src/__tests__/utils/utils.test.ts +203 -0
  138. package/src/autopilot/autopilot-runner.ts +503 -0
  139. package/src/autopilot/index.ts +6 -0
  140. package/src/autopilot/types.ts +119 -0
  141. package/src/cache/index.ts +7 -0
  142. package/src/cache/redis-cache.d.ts +155 -0
  143. package/src/cache/redis-cache.d.ts.map +1 -0
  144. package/src/cache/redis-cache.ts +517 -0
  145. package/src/ci/github-actions.ts +335 -0
  146. package/src/ci/index.ts +12 -0
  147. package/src/ci/pre-commit.ts +338 -0
  148. package/src/db/usage-schema.prisma +114 -0
  149. package/src/entitlements.ts +570 -0
  150. package/src/env.d.ts +68 -0
  151. package/src/env.d.ts.map +1 -0
  152. package/src/env.ts +247 -0
  153. package/src/fix-packs/__tests__/generate-fix-packs.test.ts +317 -0
  154. package/src/fix-packs/generate-fix-packs.ts +577 -0
  155. package/src/fix-packs/index.ts +8 -0
  156. package/src/fix-packs/types.ts +206 -0
  157. package/src/index.d.ts +7 -0
  158. package/src/index.d.ts.map +1 -0
  159. package/src/index.ts +12 -0
  160. package/src/metrics/prometheus.d.ts +104 -0
  161. package/src/metrics/prometheus.d.ts.map +1 -0
  162. package/src/metrics/prometheus.ts +446 -0
  163. package/src/quota-ledger.ts +548 -0
  164. package/src/rbac/__tests__/permissions.test.ts +446 -0
  165. package/src/rbac/index.ts +46 -0
  166. package/src/rbac/permissions.ts +301 -0
  167. package/src/rbac/types.ts +298 -0
  168. package/src/tier-config.json +157 -0
  169. package/src/tier-config.ts +815 -0
  170. package/src/types.d.ts +365 -0
  171. package/src/types.d.ts.map +1 -0
  172. package/src/types.ts +441 -0
  173. package/src/utils.d.ts +36 -0
  174. package/src/utils.d.ts.map +1 -0
  175. package/src/utils.ts +140 -0
  176. package/src/verified-autofix/__tests__/format-validator.test.ts +335 -0
  177. package/src/verified-autofix/__tests__/pipeline.test.ts +419 -0
  178. package/src/verified-autofix/__tests__/repo-fingerprint.test.ts +241 -0
  179. package/src/verified-autofix/__tests__/workspace.test.ts +373 -0
  180. package/src/verified-autofix/format-validator.ts +517 -0
  181. package/src/verified-autofix/index.ts +63 -0
  182. package/src/verified-autofix/pipeline.ts +403 -0
  183. package/src/verified-autofix/repo-fingerprint.ts +459 -0
  184. package/src/verified-autofix/workspace.ts +531 -0
  185. package/src/verified-autofix.ts +1187 -0
  186. package/src/visualization/dependency-graph.d.ts +85 -0
  187. package/src/visualization/dependency-graph.d.ts.map +1 -0
  188. package/src/visualization/dependency-graph.ts +495 -0
  189. package/src/visualization/index.ts +5 -0
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Quota Ledger - Server-Authoritative Usage Tracking
3
+ *
4
+ * Implements idempotent usage recording with request IDs to prevent double-counting.
5
+ * All quota checks are validated server-side before allowing operations.
6
+ *
7
+ * SECURITY: CLI cannot bypass quota checks by modifying local files.
8
+ * The server is the single source of truth for usage data.
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as os from 'os';
13
+ import * as path from 'path';
14
+ import * as crypto from 'crypto';
15
+
16
+ // ============================================================================
17
+ // TYPES
18
+ // ============================================================================
19
+
20
+ export type UsageActionType =
21
+ | 'scan' // Layer 1 scan
22
+ | 'scan_truth' // Layer 1+2 scan (counts as 2 for free tier)
23
+ | 'reality' // Layer 3 reality run
24
+ | 'agent' // AI Agent run
25
+ | 'fix' // Verified autofix run
26
+ | 'gate'; // CI gate check
27
+
28
+ export interface UsageEntry {
29
+ id: string; // Unique request ID for idempotency
30
+ action: UsageActionType;
31
+ count: number;
32
+ timestamp: string;
33
+ synced: boolean; // Whether recorded on server
34
+ projectId?: string;
35
+ metadata?: Record<string, unknown>;
36
+ }
37
+
38
+ export interface QuotaLimits {
39
+ scans: number; // -1 = unlimited
40
+ reality: number;
41
+ agent: number;
42
+ }
43
+
44
+ export interface UsageSummary {
45
+ tier: string;
46
+ period: {
47
+ start: string;
48
+ end: string;
49
+ };
50
+ usage: {
51
+ scans: number;
52
+ reality: number;
53
+ agent: number;
54
+ fix: number;
55
+ gate: number;
56
+ };
57
+ limits: QuotaLimits;
58
+ remaining: {
59
+ scans: number;
60
+ reality: number;
61
+ agent: number;
62
+ };
63
+ }
64
+
65
+ export interface QuotaCheckResult {
66
+ allowed: boolean;
67
+ current: number;
68
+ limit: number;
69
+ remaining: number;
70
+ reason?: string;
71
+ source: 'server' | 'cache' | 'offline';
72
+ requestId: string;
73
+ }
74
+
75
+ export interface RecordResult {
76
+ success: boolean;
77
+ requestId: string;
78
+ newUsage: number;
79
+ remaining: number;
80
+ source: 'server' | 'queued';
81
+ error?: string;
82
+ }
83
+
84
+ // ============================================================================
85
+ // QUOTA LEDGER CLASS
86
+ // ============================================================================
87
+
88
+ export class QuotaLedger {
89
+ private configDir: string;
90
+ private ledgerFile: string;
91
+ private cacheFile: string;
92
+ private apiUrl: string;
93
+ private apiKey: string | null;
94
+
95
+ constructor() {
96
+ this.configDir = path.join(os.homedir(), '.guardrail');
97
+ this.ledgerFile = path.join(this.configDir, 'usage-ledger.json');
98
+ this.cacheFile = path.join(this.configDir, 'quota-cache.json');
99
+ this.apiUrl = process.env['GUARDRAIL_API_URL'] || 'https://api.getguardrail.io';
100
+ this.apiKey = process.env['GUARDRAIL_API_KEY'] || null;
101
+ }
102
+
103
+ /**
104
+ * Generate unique request ID for idempotency
105
+ */
106
+ generateRequestId(): string {
107
+ const timestamp = Date.now().toString(36);
108
+ const random = crypto.randomBytes(8).toString('hex');
109
+ return `req_${timestamp}_${random}`;
110
+ }
111
+
112
+ /**
113
+ * Check if action is allowed based on quota
114
+ * Always validates with server when possible
115
+ */
116
+ async checkQuota(action: UsageActionType): Promise<QuotaCheckResult> {
117
+ const requestId = this.generateRequestId();
118
+
119
+ // Try server-authoritative check first
120
+ if (this.apiKey) {
121
+ try {
122
+ const response = await fetch(`${this.apiUrl}/api/usage/check`, {
123
+ method: 'POST',
124
+ headers: {
125
+ 'Content-Type': 'application/json',
126
+ 'Authorization': `Bearer ${this.apiKey}`,
127
+ 'X-Request-ID': requestId,
128
+ },
129
+ body: JSON.stringify({ action }),
130
+ });
131
+
132
+ if (response.ok) {
133
+ const result = await response.json() as {
134
+ allowed: boolean;
135
+ current: number;
136
+ limit: number;
137
+ remaining: number;
138
+ reason?: string;
139
+ };
140
+
141
+ // Cache the result
142
+ await this.cacheQuotaResult(action, result);
143
+
144
+ return {
145
+ ...result,
146
+ source: 'server',
147
+ requestId,
148
+ };
149
+ }
150
+ } catch {
151
+ // Server unreachable, fall back to cache/offline
152
+ }
153
+ }
154
+
155
+ // Fallback to cached data
156
+ const cached = await this.getCachedQuota(action);
157
+ if (cached) {
158
+ return {
159
+ ...cached,
160
+ source: 'cache',
161
+ requestId,
162
+ };
163
+ }
164
+
165
+ // Offline mode - allow with warning
166
+ return {
167
+ allowed: true,
168
+ current: 0,
169
+ limit: -1,
170
+ remaining: -1,
171
+ reason: 'Offline mode - usage will be synced when online',
172
+ source: 'offline',
173
+ requestId,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Record usage with idempotency guarantee
179
+ * Uses request ID to prevent double-counting on retries
180
+ */
181
+ async recordUsage(
182
+ action: UsageActionType,
183
+ count: number = 1,
184
+ requestId?: string
185
+ ): Promise<RecordResult> {
186
+ const id = requestId || this.generateRequestId();
187
+
188
+ // Check for duplicate request
189
+ if (await this.isDuplicateRequest(id)) {
190
+ const existing = await this.getExistingResult(id);
191
+ if (existing) return existing;
192
+ }
193
+
194
+ const entry: UsageEntry = {
195
+ id,
196
+ action,
197
+ count,
198
+ timestamp: new Date().toISOString(),
199
+ synced: false,
200
+ };
201
+
202
+ // Try server-authoritative recording
203
+ if (this.apiKey) {
204
+ try {
205
+ const response = await fetch(`${this.apiUrl}/api/usage/record`, {
206
+ method: 'POST',
207
+ headers: {
208
+ 'Content-Type': 'application/json',
209
+ 'Authorization': `Bearer ${this.apiKey}`,
210
+ 'X-Request-ID': id,
211
+ 'X-Idempotency-Key': id,
212
+ },
213
+ body: JSON.stringify({ action, count, requestId: id }),
214
+ });
215
+
216
+ if (response.ok) {
217
+ const result = await response.json() as {
218
+ success: boolean;
219
+ newUsage: number;
220
+ remaining: number;
221
+ };
222
+
223
+ entry.synced = true;
224
+ await this.saveLedgerEntry(entry);
225
+
226
+ return {
227
+ success: true,
228
+ requestId: id,
229
+ newUsage: result.newUsage,
230
+ remaining: result.remaining,
231
+ source: 'server',
232
+ };
233
+ }
234
+
235
+ // Handle rate limit or quota exceeded
236
+ if (response.status === 429 || response.status === 402) {
237
+ const error = await response.json() as { message: string };
238
+ return {
239
+ success: false,
240
+ requestId: id,
241
+ newUsage: 0,
242
+ remaining: 0,
243
+ source: 'server',
244
+ error: error.message || 'Quota exceeded',
245
+ };
246
+ }
247
+ } catch {
248
+ // Server unreachable, queue for later sync
249
+ }
250
+ }
251
+
252
+ // Queue for later sync
253
+ await this.saveLedgerEntry(entry);
254
+
255
+ return {
256
+ success: true,
257
+ requestId: id,
258
+ newUsage: count,
259
+ remaining: -1,
260
+ source: 'queued',
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Get usage summary for current billing period
266
+ */
267
+ async getUsageSummary(): Promise<UsageSummary | null> {
268
+ if (this.apiKey) {
269
+ try {
270
+ const response = await fetch(`${this.apiUrl}/api/usage/summary`, {
271
+ method: 'GET',
272
+ headers: {
273
+ 'Authorization': `Bearer ${this.apiKey}`,
274
+ },
275
+ });
276
+
277
+ if (response.ok) {
278
+ return await response.json() as UsageSummary;
279
+ }
280
+ } catch {
281
+ // Fall through to local
282
+ }
283
+ }
284
+
285
+ // Return local summary
286
+ return this.getLocalSummary();
287
+ }
288
+
289
+ /**
290
+ * Sync pending offline usage to server
291
+ */
292
+ async syncPendingUsage(): Promise<{ synced: number; failed: number; errors: string[] }> {
293
+ const entries = await this.getUnsyncedEntries();
294
+ let synced = 0;
295
+ let failed = 0;
296
+ const errors: string[] = [];
297
+
298
+ if (!this.apiKey || entries.length === 0) {
299
+ return { synced: 0, failed: 0, errors: [] };
300
+ }
301
+
302
+ for (const entry of entries) {
303
+ try {
304
+ const response = await fetch(`${this.apiUrl}/api/usage/record`, {
305
+ method: 'POST',
306
+ headers: {
307
+ 'Content-Type': 'application/json',
308
+ 'Authorization': `Bearer ${this.apiKey}`,
309
+ 'X-Request-ID': entry.id,
310
+ 'X-Idempotency-Key': entry.id,
311
+ },
312
+ body: JSON.stringify({
313
+ action: entry.action,
314
+ count: entry.count,
315
+ requestId: entry.id,
316
+ timestamp: entry.timestamp,
317
+ }),
318
+ });
319
+
320
+ if (response.ok) {
321
+ entry.synced = true;
322
+ await this.updateLedgerEntry(entry);
323
+ synced++;
324
+ } else {
325
+ failed++;
326
+ errors.push(`Failed to sync ${entry.id}: ${response.statusText}`);
327
+ }
328
+ } catch (e) {
329
+ failed++;
330
+ errors.push(`Failed to sync ${entry.id}: ${(e as Error).message}`);
331
+ }
332
+ }
333
+
334
+ return { synced, failed, errors };
335
+ }
336
+
337
+ /**
338
+ * Get count of pending offline usage
339
+ */
340
+ async getPendingCount(): Promise<number> {
341
+ const entries = await this.getUnsyncedEntries();
342
+ return entries.length;
343
+ }
344
+
345
+ // ============================================================================
346
+ // PRIVATE HELPERS
347
+ // ============================================================================
348
+
349
+ private async ensureDir(): Promise<void> {
350
+ try {
351
+ await fs.promises.mkdir(this.configDir, { recursive: true });
352
+ } catch {
353
+ // Exists
354
+ }
355
+ }
356
+
357
+ private async saveLedgerEntry(entry: UsageEntry): Promise<void> {
358
+ await this.ensureDir();
359
+
360
+ let ledger: UsageEntry[] = [];
361
+ try {
362
+ const content = await fs.promises.readFile(this.ledgerFile, 'utf8');
363
+ ledger = JSON.parse(content);
364
+ } catch {
365
+ // New file
366
+ }
367
+
368
+ // Check for duplicate
369
+ const existing = ledger.findIndex(e => e.id === entry.id);
370
+ if (existing >= 0) {
371
+ ledger[existing] = entry;
372
+ } else {
373
+ ledger.push(entry);
374
+ }
375
+
376
+ // Keep only last 1000 entries
377
+ if (ledger.length > 1000) {
378
+ ledger = ledger.slice(-1000);
379
+ }
380
+
381
+ await fs.promises.writeFile(this.ledgerFile, JSON.stringify(ledger, null, 2));
382
+ }
383
+
384
+ private async updateLedgerEntry(entry: UsageEntry): Promise<void> {
385
+ await this.saveLedgerEntry(entry);
386
+ }
387
+
388
+ private async getUnsyncedEntries(): Promise<UsageEntry[]> {
389
+ try {
390
+ const content = await fs.promises.readFile(this.ledgerFile, 'utf8');
391
+ const ledger: UsageEntry[] = JSON.parse(content);
392
+ return ledger.filter(e => !e.synced);
393
+ } catch {
394
+ return [];
395
+ }
396
+ }
397
+
398
+ private async isDuplicateRequest(id: string): Promise<boolean> {
399
+ try {
400
+ const content = await fs.promises.readFile(this.ledgerFile, 'utf8');
401
+ const ledger: UsageEntry[] = JSON.parse(content);
402
+ return ledger.some(e => e.id === id);
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+
408
+ private async getExistingResult(id: string): Promise<RecordResult | null> {
409
+ try {
410
+ const content = await fs.promises.readFile(this.ledgerFile, 'utf8');
411
+ const ledger: UsageEntry[] = JSON.parse(content);
412
+ const entry = ledger.find(e => e.id === id);
413
+ if (entry) {
414
+ return {
415
+ success: true,
416
+ requestId: entry.id,
417
+ newUsage: entry.count,
418
+ remaining: -1,
419
+ source: entry.synced ? 'server' : 'queued',
420
+ };
421
+ }
422
+ } catch {
423
+ // Not found
424
+ }
425
+ return null;
426
+ }
427
+
428
+ private async cacheQuotaResult(
429
+ action: UsageActionType,
430
+ result: { allowed: boolean; current: number; limit: number; remaining: number }
431
+ ): Promise<void> {
432
+ await this.ensureDir();
433
+
434
+ let cache: Record<string, unknown> = {};
435
+ try {
436
+ const content = await fs.promises.readFile(this.cacheFile, 'utf8');
437
+ cache = JSON.parse(content);
438
+ } catch {
439
+ // New cache
440
+ }
441
+
442
+ cache[action] = {
443
+ ...result,
444
+ cachedAt: new Date().toISOString(),
445
+ };
446
+
447
+ await fs.promises.writeFile(this.cacheFile, JSON.stringify(cache, null, 2));
448
+ }
449
+
450
+ private async getCachedQuota(action: UsageActionType): Promise<QuotaCheckResult | null> {
451
+ try {
452
+ const content = await fs.promises.readFile(this.cacheFile, 'utf8');
453
+ const cache = JSON.parse(content);
454
+ const cached = cache[action];
455
+
456
+ if (cached) {
457
+ // Cache valid for 5 minutes
458
+ const age = Date.now() - new Date(cached.cachedAt).getTime();
459
+ if (age < 5 * 60 * 1000) {
460
+ return {
461
+ allowed: cached.allowed,
462
+ current: cached.current,
463
+ limit: cached.limit,
464
+ remaining: cached.remaining,
465
+ source: 'cache',
466
+ requestId: this.generateRequestId(),
467
+ };
468
+ }
469
+ }
470
+ } catch {
471
+ // No cache
472
+ }
473
+ return null;
474
+ }
475
+
476
+ private async getLocalSummary(): Promise<UsageSummary | null> {
477
+ try {
478
+ const content = await fs.promises.readFile(this.ledgerFile, 'utf8');
479
+ const ledger: UsageEntry[] = JSON.parse(content);
480
+
481
+ // Calculate current month's usage
482
+ const now = new Date();
483
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
484
+ const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
485
+
486
+ const thisMonth = ledger.filter(e => {
487
+ const ts = new Date(e.timestamp);
488
+ return ts >= monthStart && ts <= monthEnd;
489
+ });
490
+
491
+ const usage = {
492
+ scans: 0,
493
+ reality: 0,
494
+ agent: 0,
495
+ fix: 0,
496
+ gate: 0,
497
+ };
498
+
499
+ for (const entry of thisMonth) {
500
+ switch (entry.action) {
501
+ case 'scan':
502
+ case 'scan_truth':
503
+ usage.scans += entry.count;
504
+ break;
505
+ case 'reality':
506
+ usage.reality += entry.count;
507
+ break;
508
+ case 'agent':
509
+ usage.agent += entry.count;
510
+ break;
511
+ case 'fix':
512
+ usage.fix += entry.count;
513
+ break;
514
+ case 'gate':
515
+ usage.gate += entry.count;
516
+ break;
517
+ }
518
+ }
519
+
520
+ return {
521
+ tier: 'unknown',
522
+ period: {
523
+ start: monthStart.toISOString(),
524
+ end: monthEnd.toISOString(),
525
+ },
526
+ usage,
527
+ limits: { scans: -1, reality: -1, agent: -1 },
528
+ remaining: { scans: -1, reality: -1, agent: -1 },
529
+ };
530
+ } catch {
531
+ return null;
532
+ }
533
+ }
534
+ }
535
+
536
+ // ============================================================================
537
+ // SINGLETON EXPORT
538
+ // ============================================================================
539
+
540
+ export const quotaLedger = new QuotaLedger();
541
+
542
+ // Convenience exports
543
+ export const checkQuota = (action: UsageActionType) => quotaLedger.checkQuota(action);
544
+ export const recordUsage = (action: UsageActionType, count?: number, requestId?: string) =>
545
+ quotaLedger.recordUsage(action, count, requestId);
546
+ export const getUsageSummary = () => quotaLedger.getUsageSummary();
547
+ export const syncPendingUsage = () => quotaLedger.syncPendingUsage();
548
+ export const getPendingCount = () => quotaLedger.getPendingCount();