speclock 2.1.1 → 3.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.
- package/README.md +55 -5
- package/package.json +10 -4
- package/src/cli/index.js +247 -3
- package/src/core/auth.js +341 -0
- package/src/core/compliance.js +1 -1
- package/src/core/conflict.js +363 -0
- package/src/core/crypto.js +158 -0
- package/src/core/enforcer.js +314 -0
- package/src/core/engine.js +111 -781
- package/src/core/memory.js +191 -0
- package/src/core/pre-commit-semantic.js +284 -0
- package/src/core/sessions.js +128 -0
- package/src/core/storage.js +23 -4
- package/src/core/tracking.js +98 -0
- package/src/mcp/http-server.js +134 -7
- package/src/mcp/server.js +206 -4
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Encrypted Storage
|
|
3
|
+
* AES-256-GCM encryption for brain.json and events.log at rest.
|
|
4
|
+
*
|
|
5
|
+
* Key derivation: PBKDF2 from SPECLOCK_ENCRYPTION_KEY env var
|
|
6
|
+
* Transparent: encrypt on write, decrypt on read
|
|
7
|
+
* Format: Base64(IV:AuthTag:CipherText) per line/file
|
|
8
|
+
*
|
|
9
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
|
|
14
|
+
const ALGORITHM = "aes-256-gcm";
|
|
15
|
+
const IV_LENGTH = 16;
|
|
16
|
+
const AUTH_TAG_LENGTH = 16;
|
|
17
|
+
const SALT = "speclock-v3-salt"; // Static salt (key is already strong from env)
|
|
18
|
+
const ITERATIONS = 100000;
|
|
19
|
+
const KEY_LENGTH = 32;
|
|
20
|
+
const ENCRYPTED_MARKER = "SPECLOCK_ENCRYPTED:";
|
|
21
|
+
|
|
22
|
+
// --- Key Derivation ---
|
|
23
|
+
|
|
24
|
+
let _derivedKey = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if encryption is enabled (env var set).
|
|
28
|
+
*/
|
|
29
|
+
export function isEncryptionEnabled() {
|
|
30
|
+
return !!process.env.SPECLOCK_ENCRYPTION_KEY;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Derive a 256-bit key from the master password.
|
|
35
|
+
*/
|
|
36
|
+
export function deriveKey(masterKey) {
|
|
37
|
+
if (!masterKey) {
|
|
38
|
+
throw new Error("SPECLOCK_ENCRYPTION_KEY is required for encryption.");
|
|
39
|
+
}
|
|
40
|
+
return crypto.pbkdf2Sync(masterKey, SALT, ITERATIONS, KEY_LENGTH, "sha512");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get or derive the encryption key from env var.
|
|
45
|
+
*/
|
|
46
|
+
function getKey() {
|
|
47
|
+
if (_derivedKey) return _derivedKey;
|
|
48
|
+
const masterKey = process.env.SPECLOCK_ENCRYPTION_KEY;
|
|
49
|
+
if (!masterKey) {
|
|
50
|
+
throw new Error("SPECLOCK_ENCRYPTION_KEY environment variable is not set.");
|
|
51
|
+
}
|
|
52
|
+
_derivedKey = deriveKey(masterKey);
|
|
53
|
+
return _derivedKey;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clear cached key (for testing).
|
|
58
|
+
*/
|
|
59
|
+
export function clearKeyCache() {
|
|
60
|
+
_derivedKey = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Encrypt / Decrypt ---
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Encrypt a string. Returns: SPECLOCK_ENCRYPTED:<base64(iv:tag:ciphertext)>
|
|
67
|
+
*/
|
|
68
|
+
export function encrypt(plaintext) {
|
|
69
|
+
const key = getKey();
|
|
70
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
71
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
72
|
+
|
|
73
|
+
let encrypted = cipher.update(plaintext, "utf-8");
|
|
74
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
75
|
+
const authTag = cipher.getAuthTag();
|
|
76
|
+
|
|
77
|
+
// Pack: IV + AuthTag + Ciphertext
|
|
78
|
+
const packed = Buffer.concat([iv, authTag, encrypted]);
|
|
79
|
+
return ENCRYPTED_MARKER + packed.toString("base64");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decrypt a string. Input: SPECLOCK_ENCRYPTED:<base64(iv:tag:ciphertext)>
|
|
84
|
+
*/
|
|
85
|
+
export function decrypt(ciphertext) {
|
|
86
|
+
if (!ciphertext.startsWith(ENCRYPTED_MARKER)) {
|
|
87
|
+
// Not encrypted — return as-is (backward compatible with plaintext)
|
|
88
|
+
return ciphertext;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const key = getKey();
|
|
92
|
+
const packed = Buffer.from(ciphertext.slice(ENCRYPTED_MARKER.length), "base64");
|
|
93
|
+
|
|
94
|
+
if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) {
|
|
95
|
+
throw new Error("Invalid encrypted data: too short.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const iv = packed.subarray(0, IV_LENGTH);
|
|
99
|
+
const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
100
|
+
const encrypted = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
101
|
+
|
|
102
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
103
|
+
decipher.setAuthTag(authTag);
|
|
104
|
+
|
|
105
|
+
let decrypted = decipher.update(encrypted);
|
|
106
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
107
|
+
return decrypted.toString("utf-8");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a string is encrypted.
|
|
112
|
+
*/
|
|
113
|
+
export function isEncrypted(data) {
|
|
114
|
+
return typeof data === "string" && data.startsWith(ENCRYPTED_MARKER);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- File-level helpers ---
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Encrypt a JSON object for storage.
|
|
121
|
+
*/
|
|
122
|
+
export function encryptJSON(obj) {
|
|
123
|
+
const json = JSON.stringify(obj, null, 2);
|
|
124
|
+
return encrypt(json);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decrypt and parse a JSON string.
|
|
129
|
+
*/
|
|
130
|
+
export function decryptJSON(data) {
|
|
131
|
+
const json = decrypt(data);
|
|
132
|
+
return JSON.parse(json);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Encrypt each line of an events log (JSONL format).
|
|
137
|
+
* Each line is encrypted independently.
|
|
138
|
+
*/
|
|
139
|
+
export function encryptLines(text) {
|
|
140
|
+
if (!text || !text.trim()) return text;
|
|
141
|
+
const lines = text.trim().split("\n");
|
|
142
|
+
return lines.map(line => {
|
|
143
|
+
if (!line.trim()) return line;
|
|
144
|
+
return encrypt(line);
|
|
145
|
+
}).join("\n") + "\n";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decrypt each line of an encrypted events log.
|
|
150
|
+
*/
|
|
151
|
+
export function decryptLines(text) {
|
|
152
|
+
if (!text || !text.trim()) return text;
|
|
153
|
+
const lines = text.trim().split("\n");
|
|
154
|
+
return lines.map(line => {
|
|
155
|
+
if (!line.trim()) return line;
|
|
156
|
+
return decrypt(line);
|
|
157
|
+
}).join("\n") + "\n";
|
|
158
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Hard Enforcement Engine
|
|
3
|
+
* Moves from advisory-only to blocking enforcement.
|
|
4
|
+
*
|
|
5
|
+
* Modes:
|
|
6
|
+
* - "advisory" (default): Returns warnings, AI decides what to do
|
|
7
|
+
* - "hard": Returns isError:true in MCP, exit code 1 in CLI, 409 in HTTP
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Configurable block threshold (default 70%)
|
|
11
|
+
* - Override mechanism with reason logging to audit trail
|
|
12
|
+
* - Escalation: 3+ overrides on same lock → auto-note for review
|
|
13
|
+
*
|
|
14
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readBrain,
|
|
19
|
+
writeBrain,
|
|
20
|
+
appendEvent,
|
|
21
|
+
bumpEvents,
|
|
22
|
+
newId,
|
|
23
|
+
nowIso,
|
|
24
|
+
addViolation,
|
|
25
|
+
} from "./storage.js";
|
|
26
|
+
import { analyzeConflict } from "./semantics.js";
|
|
27
|
+
|
|
28
|
+
// --- Enforcement config helpers ---
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get enforcement config from brain, with defaults.
|
|
32
|
+
*/
|
|
33
|
+
export function getEnforcementConfig(brain) {
|
|
34
|
+
const defaults = {
|
|
35
|
+
mode: "advisory", // "advisory" | "hard"
|
|
36
|
+
blockThreshold: 70, // minimum confidence % to block in hard mode
|
|
37
|
+
allowOverride: true, // whether overrides are permitted
|
|
38
|
+
escalationLimit: 3, // overrides before auto-note
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (!brain.enforcement) return { ...defaults };
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
...defaults,
|
|
45
|
+
...brain.enforcement,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set enforcement mode on a project.
|
|
51
|
+
*/
|
|
52
|
+
export function setEnforcementMode(root, mode, options = {}) {
|
|
53
|
+
const brain = readBrain(root);
|
|
54
|
+
if (!brain) {
|
|
55
|
+
return { success: false, error: "SpecLock not initialized." };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (mode !== "advisory" && mode !== "hard") {
|
|
59
|
+
return { success: false, error: `Invalid mode: "${mode}". Must be "advisory" or "hard".` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!brain.enforcement) {
|
|
63
|
+
brain.enforcement = {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
brain.enforcement.mode = mode;
|
|
67
|
+
if (options.blockThreshold !== undefined) {
|
|
68
|
+
brain.enforcement.blockThreshold = Math.max(0, Math.min(100, options.blockThreshold));
|
|
69
|
+
}
|
|
70
|
+
if (options.allowOverride !== undefined) {
|
|
71
|
+
brain.enforcement.allowOverride = !!options.allowOverride;
|
|
72
|
+
}
|
|
73
|
+
if (options.escalationLimit !== undefined) {
|
|
74
|
+
brain.enforcement.escalationLimit = Math.max(1, options.escalationLimit);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const eventId = newId("evt");
|
|
78
|
+
const event = {
|
|
79
|
+
eventId,
|
|
80
|
+
type: "enforcement_mode_changed",
|
|
81
|
+
at: nowIso(),
|
|
82
|
+
files: [],
|
|
83
|
+
summary: `Enforcement mode set to: ${mode}${options.blockThreshold ? ` (threshold: ${options.blockThreshold}%)` : ""}`,
|
|
84
|
+
patchPath: "",
|
|
85
|
+
};
|
|
86
|
+
bumpEvents(brain, eventId);
|
|
87
|
+
appendEvent(root, event);
|
|
88
|
+
writeBrain(root, brain);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
success: true,
|
|
92
|
+
mode,
|
|
93
|
+
config: getEnforcementConfig(brain),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Enforce a conflict check — returns enriched result with enforcement metadata.
|
|
99
|
+
* This wraps the existing checkConflict logic with hard/advisory behavior.
|
|
100
|
+
*/
|
|
101
|
+
export function enforceConflictCheck(root, proposedAction) {
|
|
102
|
+
const brain = readBrain(root);
|
|
103
|
+
if (!brain) {
|
|
104
|
+
return {
|
|
105
|
+
hasConflict: false,
|
|
106
|
+
blocked: false,
|
|
107
|
+
mode: "advisory",
|
|
108
|
+
conflictingLocks: [],
|
|
109
|
+
analysis: "SpecLock not initialized. No enforcement.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const config = getEnforcementConfig(brain);
|
|
114
|
+
const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
|
|
115
|
+
|
|
116
|
+
if (activeLocks.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
hasConflict: false,
|
|
119
|
+
blocked: false,
|
|
120
|
+
mode: config.mode,
|
|
121
|
+
conflictingLocks: [],
|
|
122
|
+
analysis: "No active locks. No constraints to check against.",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Run semantic analysis against all active locks
|
|
127
|
+
const conflicting = [];
|
|
128
|
+
for (const lock of activeLocks) {
|
|
129
|
+
const result = analyzeConflict(proposedAction, lock.text);
|
|
130
|
+
if (result.isConflict) {
|
|
131
|
+
conflicting.push({
|
|
132
|
+
id: lock.id,
|
|
133
|
+
text: lock.text,
|
|
134
|
+
confidence: result.confidence,
|
|
135
|
+
level: result.level,
|
|
136
|
+
reasons: result.reasons,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (conflicting.length === 0) {
|
|
142
|
+
return {
|
|
143
|
+
hasConflict: false,
|
|
144
|
+
blocked: false,
|
|
145
|
+
mode: config.mode,
|
|
146
|
+
conflictingLocks: [],
|
|
147
|
+
analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Sort by confidence descending
|
|
152
|
+
conflicting.sort((a, b) => b.confidence - a.confidence);
|
|
153
|
+
|
|
154
|
+
// Determine if this should be BLOCKED (hard mode + above threshold)
|
|
155
|
+
const topConfidence = conflicting[0].confidence;
|
|
156
|
+
const meetsThreshold = topConfidence >= config.blockThreshold;
|
|
157
|
+
const blocked = config.mode === "hard" && meetsThreshold;
|
|
158
|
+
|
|
159
|
+
const details = conflicting
|
|
160
|
+
.map(
|
|
161
|
+
(c) =>
|
|
162
|
+
`- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
|
|
163
|
+
)
|
|
164
|
+
.join("\n");
|
|
165
|
+
|
|
166
|
+
// Record violation
|
|
167
|
+
addViolation(brain, {
|
|
168
|
+
at: nowIso(),
|
|
169
|
+
action: proposedAction,
|
|
170
|
+
locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
|
|
171
|
+
topLevel: conflicting[0].level,
|
|
172
|
+
topConfidence,
|
|
173
|
+
enforced: blocked,
|
|
174
|
+
mode: config.mode,
|
|
175
|
+
});
|
|
176
|
+
writeBrain(root, brain);
|
|
177
|
+
|
|
178
|
+
const modeLabel = blocked
|
|
179
|
+
? "BLOCKED — Hard enforcement active. This action cannot proceed."
|
|
180
|
+
: "WARNING — Advisory mode. Review before proceeding.";
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
hasConflict: true,
|
|
184
|
+
blocked,
|
|
185
|
+
mode: config.mode,
|
|
186
|
+
threshold: config.blockThreshold,
|
|
187
|
+
topConfidence,
|
|
188
|
+
conflictingLocks: conflicting,
|
|
189
|
+
analysis: `${modeLabel}\n\nConflict with ${conflicting.length} lock(s):\n${details}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Override a lock for a specific action, with a reason.
|
|
195
|
+
* Logged to audit trail. Triggers escalation if overridden too many times.
|
|
196
|
+
*/
|
|
197
|
+
export function overrideLock(root, lockId, action, reason) {
|
|
198
|
+
const brain = readBrain(root);
|
|
199
|
+
if (!brain) {
|
|
200
|
+
return { success: false, error: "SpecLock not initialized." };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const config = getEnforcementConfig(brain);
|
|
204
|
+
if (!config.allowOverride) {
|
|
205
|
+
return { success: false, error: "Overrides are disabled for this project." };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const lock = (brain.specLock?.items || []).find((l) => l.id === lockId);
|
|
209
|
+
if (!lock) {
|
|
210
|
+
return { success: false, error: `Lock not found: ${lockId}` };
|
|
211
|
+
}
|
|
212
|
+
if (!lock.active) {
|
|
213
|
+
return { success: false, error: `Lock is already inactive: ${lockId}` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Initialize overrides tracking on the lock
|
|
217
|
+
if (!lock.overrides) lock.overrides = [];
|
|
218
|
+
lock.overrides.push({
|
|
219
|
+
at: nowIso(),
|
|
220
|
+
action,
|
|
221
|
+
reason,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Record override event in audit trail
|
|
225
|
+
const eventId = newId("evt");
|
|
226
|
+
const event = {
|
|
227
|
+
eventId,
|
|
228
|
+
type: "lock_overridden",
|
|
229
|
+
at: nowIso(),
|
|
230
|
+
files: [],
|
|
231
|
+
summary: `Lock overridden: "${lock.text.substring(0, 60)}" — Reason: ${reason.substring(0, 100)}`,
|
|
232
|
+
patchPath: "",
|
|
233
|
+
meta: { lockId, action, reason },
|
|
234
|
+
};
|
|
235
|
+
bumpEvents(brain, eventId);
|
|
236
|
+
appendEvent(root, event);
|
|
237
|
+
|
|
238
|
+
// Check for escalation
|
|
239
|
+
let escalated = false;
|
|
240
|
+
const overrideCount = lock.overrides.length;
|
|
241
|
+
if (overrideCount >= config.escalationLimit) {
|
|
242
|
+
escalated = true;
|
|
243
|
+
|
|
244
|
+
// Auto-create a note flagging this for review
|
|
245
|
+
const noteId = newId("note");
|
|
246
|
+
brain.notes.unshift({
|
|
247
|
+
id: noteId,
|
|
248
|
+
text: `ESCALATION: Lock "${lock.text}" has been overridden ${overrideCount} times. Review whether this lock is still appropriate or if it should be removed.`,
|
|
249
|
+
createdAt: nowIso(),
|
|
250
|
+
pinned: true,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const noteEventId = newId("evt");
|
|
254
|
+
const noteEvent = {
|
|
255
|
+
eventId: noteEventId,
|
|
256
|
+
type: "note_added",
|
|
257
|
+
at: nowIso(),
|
|
258
|
+
files: [],
|
|
259
|
+
summary: `Escalation note: Lock "${lock.text.substring(0, 40)}" overridden ${overrideCount} times`,
|
|
260
|
+
patchPath: "",
|
|
261
|
+
};
|
|
262
|
+
bumpEvents(brain, noteEventId);
|
|
263
|
+
appendEvent(root, noteEvent);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
writeBrain(root, brain);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
success: true,
|
|
270
|
+
lockId,
|
|
271
|
+
lockText: lock.text,
|
|
272
|
+
overrideCount,
|
|
273
|
+
escalated,
|
|
274
|
+
escalationMessage: escalated
|
|
275
|
+
? `WARNING: This lock has been overridden ${overrideCount} times (limit: ${config.escalationLimit}). An escalation note has been created for review.`
|
|
276
|
+
: null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get override history for a specific lock or all locks.
|
|
282
|
+
*/
|
|
283
|
+
export function getOverrideHistory(root, lockId = null) {
|
|
284
|
+
const brain = readBrain(root);
|
|
285
|
+
if (!brain) {
|
|
286
|
+
return { overrides: [], total: 0 };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const locks = (brain.specLock?.items || []).filter((l) => {
|
|
290
|
+
if (lockId) return l.id === lockId;
|
|
291
|
+
return l.overrides && l.overrides.length > 0;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const overrides = [];
|
|
295
|
+
for (const lock of locks) {
|
|
296
|
+
if (!lock.overrides) continue;
|
|
297
|
+
for (const ov of lock.overrides) {
|
|
298
|
+
overrides.push({
|
|
299
|
+
lockId: lock.id,
|
|
300
|
+
lockText: lock.text,
|
|
301
|
+
lockActive: lock.active,
|
|
302
|
+
...ov,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Sort by date descending
|
|
308
|
+
overrides.sort((a, b) => (b.at > a.at ? 1 : -1));
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
overrides,
|
|
312
|
+
total: overrides.length,
|
|
313
|
+
};
|
|
314
|
+
}
|