kreftjs 0.0.21-beta → 0.0.23-beta.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/bin/artisan.js +4 -2
- package/bin/backend/cache.js +31 -5
- package/bin/backend/gc.js +193 -43
- package/bin/backend/index.js +2 -0
- package/bin/backend/mcp-client.js +133 -0
- package/bin/backend/mcp.js +17 -0
- package/bin/backend/model.js +15 -1
- package/bin/backend/shield.js +266 -0
- package/bin/build.js +4 -1
- package/bin/db-api.js +9 -1
- package/bin/kreftjs.js +26 -2
- package/bin/mobile.js +175 -0
- package/bin/pwa.js +76 -0
- package/docs/README.md +142 -2
- package/editors/sublime/Comments.tmPreferences +5 -2
- package/editors/sublime/Kreft.sublime-syntax +30 -1
- package/editors/sublime/layout.sublime-snippet +1 -1
- package/editors/sublime/section.sublime-snippet +1 -1
- package/editors/sublime/yield.sublime-snippet +1 -1
- package/kreftjs.d.ts +1 -1
- package/kreftjs.js +50 -3
- package/package.json +3 -2
- package/server.d.ts +42 -0
- package/templates/app/backend/app/Auth/Session.js +8 -4
- package/templates/app/backend/app/Http/Middleware/Authenticate.js +7 -4
- package/templates/app/backend/database/seeders/DatabaseSeeder.js +4 -0
- package/templates/app/frontend/kreftjs.d.ts +1 -1
- package/templates/app/frontend/kreftjs.js +50 -3
- package/templates/app/frontend/ui.css +15 -0
- package/book/~$eft-book-ar.docx +0 -0
package/bin/artisan.js
CHANGED
|
@@ -262,7 +262,9 @@ class ${name} {
|
|
|
262
262
|
// POST /api/${plural(snake(model))}
|
|
263
263
|
async store(req) {
|
|
264
264
|
// const data = await req.validate({ title: 'required|max:200' })
|
|
265
|
-
|
|
265
|
+
// only the columns listed in static fillable on the model are taken: never req.all(), or a visitor could send
|
|
266
|
+
// { "is_admin": true } and have it saved
|
|
267
|
+
const data = req.only(...(${model}.fillable || abort(500, 'Set static fillable on the ${model} model first (the columns a visitor may fill).')))
|
|
266
268
|
// if (data.title && !data.slug) data.slug = await slug.unique(${model}, 'slug', data.title)
|
|
267
269
|
return response(await ${model}.create(data), 201)
|
|
268
270
|
}
|
|
@@ -276,7 +278,7 @@ class ${name} {
|
|
|
276
278
|
async update(req, id) {
|
|
277
279
|
const ${snake(model)} = await ${model}.findOrFail(id)
|
|
278
280
|
// const data = await req.validate({ title: 'sometimes|required|max:200' })
|
|
279
|
-
const data = req.
|
|
281
|
+
const data = req.only(...(${model}.fillable || abort(500, 'Set static fillable on the ${model} model first (the columns a visitor may fill).')))
|
|
280
282
|
return ${snake(model)}.update(data)
|
|
281
283
|
}
|
|
282
284
|
|
package/bin/backend/cache.js
CHANGED
|
@@ -19,15 +19,41 @@ const { parseJson } = require('../safe-json.js');
|
|
|
19
19
|
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
20
20
|
const expiry = (seconds) => (seconds == null ? 0 : nowSec() + Math.max(1, Math.floor(seconds instanceof Date ? (seconds - Date.now()) / 1000 : seconds)));
|
|
21
21
|
|
|
22
|
+
// In-process store with LRU eviction: at most CACHE_MEMORY_MAX entries (default 5000), and under
|
|
23
|
+
// memory pressure the memory manager (gc.js) trims the least recently used ones instead of flushing all.
|
|
22
24
|
function memoryStore() {
|
|
23
|
-
const map = new Map();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const map = new Map(); // insertion order = recency: the first key is the least recently used
|
|
26
|
+
const max = Math.max(1, Number(process.env.CACHE_MEMORY_MAX) || 5000);
|
|
27
|
+
const set = (key, entry) => {
|
|
28
|
+
map.delete(key);
|
|
29
|
+
map.set(key, entry);
|
|
30
|
+
while (map.size > max) map.delete(map.keys().next().value);
|
|
31
|
+
};
|
|
32
|
+
const store = {
|
|
33
|
+
async get(key) {
|
|
34
|
+
const e = map.get(key);
|
|
35
|
+
if (!e) return undefined;
|
|
36
|
+
if (e.exp && e.exp <= nowSec()) { map.delete(key); return undefined; }
|
|
37
|
+
map.delete(key); map.set(key, e); // mark as recently used
|
|
38
|
+
return e.value;
|
|
39
|
+
},
|
|
40
|
+
async put(key, value, exp) { set(key, { value, exp }); },
|
|
41
|
+
async add(key, value, exp) { if ((await this.get(key)) !== undefined) return false; set(key, { value, exp }); return true; },
|
|
28
42
|
async forget(key) { return map.delete(key); },
|
|
29
43
|
async flush() { map.clear(); },
|
|
44
|
+
size: () => map.size,
|
|
45
|
+
/** Drops expired entries, then the least recently used `fraction` of what is left. Returns how many went. */
|
|
46
|
+
trim(fraction = 0.5) {
|
|
47
|
+
const before = map.size;
|
|
48
|
+
const now = nowSec();
|
|
49
|
+
for (const [k, e] of map) if (e.exp && e.exp <= now) map.delete(k);
|
|
50
|
+
let drop = Math.ceil(map.size * Math.min(1, Math.max(0, fraction)));
|
|
51
|
+
for (const k of map.keys()) { if (drop-- <= 0) break; map.delete(k); }
|
|
52
|
+
return before - map.size;
|
|
53
|
+
},
|
|
30
54
|
};
|
|
55
|
+
try { require('./gc.js').onMemoryPressure('cache:memory', (fraction) => store.trim(fraction)); } catch (_) {}
|
|
56
|
+
return store;
|
|
31
57
|
}
|
|
32
58
|
function fileStore(dir) {
|
|
33
59
|
const file = (key) => path.join(dir, crypto.createHash('sha1').update(key).digest('hex'));
|
package/bin/backend/gc.js
CHANGED
|
@@ -1,69 +1,219 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Kreft
|
|
2
|
+
* Kreft Memory Manager (Auto GC + watchdog)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* - Adaptive GC: a cleanup runs when the heap passes the threshold OR when it keeps growing
|
|
5
|
+
* (trend over the last samples) while already past 60% of the threshold.
|
|
6
|
+
* - Smart eviction: caches register a handler (onMemoryPressure) and drop their least recently
|
|
7
|
+
* used entries, a fraction at a time, instead of being flushed whole.
|
|
8
|
+
* - Leak detection: after each real GC the heap "floor" is recorded; a floor that keeps rising
|
|
9
|
+
* across collections is reported as a suspected leak.
|
|
10
|
+
* - Alerts: onMemoryAlert(fn) is called when memory stays high despite cleaning, or a leak is suspected.
|
|
11
|
+
* - Report: getMemoryReport() returns samples, GC count, evictions, leak state and alerts.
|
|
12
|
+
*
|
|
13
|
+
* Env: AUTO_GC_THRESHOLD_MB (250), AUTO_GC_INTERVAL_MS (30000), AUTO_GC_GROWTH_MB_PER_MIN (20),
|
|
14
|
+
* AUTO_GC_ALERT_AFTER (3 checks), AUTO_GC_LEAK_RISE_MB (20). `--gc` / AUTO_GC=1 exposes global.gc.
|
|
6
15
|
*/
|
|
16
|
+
const MB = 1024 * 1024;
|
|
17
|
+
const HISTORY_MAX = 120;
|
|
18
|
+
|
|
7
19
|
let gcTimer = null;
|
|
8
|
-
let
|
|
20
|
+
let config = null;
|
|
21
|
+
let observer = null;
|
|
22
|
+
|
|
23
|
+
const history = [];
|
|
24
|
+
const pressureHandlers = new Map();
|
|
25
|
+
const alertListeners = new Set();
|
|
26
|
+
const floors = [];
|
|
27
|
+
const state = freshState();
|
|
28
|
+
|
|
29
|
+
function freshState() {
|
|
30
|
+
return {
|
|
31
|
+
startedAt: null, checks: 0, cleanups: 0, gcRuns: 0, v8Collections: 0, reclaimedMB: 0,
|
|
32
|
+
evicted: 0, overStreak: 0, alerts: [], lastAlertAt: 0, leak: { suspected: false, riseMB: 0, floorsMB: [] },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const round = (n) => +(n).toFixed(2);
|
|
37
|
+
|
|
38
|
+
/** Register a handler called under memory pressure: fn(fraction) → number of entries dropped. */
|
|
39
|
+
function onMemoryPressure(name, fn) {
|
|
40
|
+
pressureHandlers.set(name, fn);
|
|
41
|
+
return () => pressureHandlers.delete(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Be told when memory stays high after cleaning, or a leak looks likely. fn(alert) */
|
|
45
|
+
function onMemoryAlert(fn) {
|
|
46
|
+
alertListeners.add(fn);
|
|
47
|
+
return () => alertListeners.delete(fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Slope of the heap in MB per minute over the most recent samples (least squares); null with too few samples.
|
|
51
|
+
function trendMBPerMin(n = 10) {
|
|
52
|
+
const pts = history.slice(-n);
|
|
53
|
+
if (pts.length < 4) return null;
|
|
54
|
+
const t0 = pts[0].t;
|
|
55
|
+
const xs = pts.map((p) => (p.t - t0) / 60000);
|
|
56
|
+
const ys = pts.map((p) => p.heapUsedMB);
|
|
57
|
+
const mx = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
58
|
+
const my = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
59
|
+
let num = 0;
|
|
60
|
+
let den = 0;
|
|
61
|
+
for (let i = 0; i < xs.length; i++) { num += (xs[i] - mx) * (ys[i] - my); den += (xs[i] - mx) ** 2; }
|
|
62
|
+
return den === 0 ? null : round(num / den);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function emitAlert(type, message, extra = {}) {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (now - state.lastAlertAt < config.alertCooldownMs) return;
|
|
68
|
+
state.lastAlertAt = now;
|
|
69
|
+
const alert = { type, message, at: new Date(now).toISOString(), ...getMemoryStats(), ...extra };
|
|
70
|
+
state.alerts.push(alert);
|
|
71
|
+
if (state.alerts.length > 20) state.alerts.shift();
|
|
72
|
+
for (const fn of alertListeners) { try { fn(alert); } catch (_) {} }
|
|
73
|
+
if (!alertListeners.size) console.warn(` [Kreft Memory] ${message}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runCleanup(fraction) {
|
|
77
|
+
state.cleanups++;
|
|
78
|
+
for (const fn of pressureHandlers.values()) {
|
|
79
|
+
try { const n = Number(fn(fraction)); if (n > 0) state.evicted += n; } catch (_) {}
|
|
80
|
+
}
|
|
81
|
+
let gcRan = false;
|
|
82
|
+
if (typeof global.gc === 'function') { global.gc(); state.gcRuns++; gcRan = true; }
|
|
83
|
+
return gcRan;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function trackLeak() {
|
|
87
|
+
const f = floors.slice(-6);
|
|
88
|
+
const rising = f.length >= 4 && f.every((v, i) => i === 0 || v >= f[i - 1] - 0.5);
|
|
89
|
+
const rise = f.length ? f[f.length - 1] - f[0] : 0;
|
|
90
|
+
state.leak = { suspected: rising && rise >= config.leakRiseMB, riseMB: round(rise), floorsMB: f.map(round) };
|
|
91
|
+
if (state.leak.suspected) {
|
|
92
|
+
emitAlert('memory-leak', `Possible memory leak: the heap after GC rose ${round(rise)} MB over the last ${f.length} collections.`, { leak: state.leak });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One check (the timer calls this; exported so it can be driven directly). */
|
|
97
|
+
function checkMemory() {
|
|
98
|
+
const mem = process.memoryUsage();
|
|
99
|
+
const heapUsedMB = mem.heapUsed / MB;
|
|
100
|
+
history.push({ t: Date.now(), heapUsedMB: round(heapUsedMB), rssMB: round(mem.rss / MB) });
|
|
101
|
+
if (history.length > HISTORY_MAX) history.shift();
|
|
102
|
+
state.checks++;
|
|
103
|
+
|
|
104
|
+
const trend = trendMBPerMin();
|
|
105
|
+
const over = heapUsedMB > config.thresholdMB;
|
|
106
|
+
const growing = trend != null && trend > config.growthMBPerMin && heapUsedMB > config.thresholdMB * 0.6;
|
|
107
|
+
if (!over && !growing) { state.overStreak = 0; return { action: 'none', heapUsedMB: round(heapUsedMB), trend }; }
|
|
108
|
+
|
|
109
|
+
const before = heapUsedMB;
|
|
110
|
+
const gcRan = runCleanup(over ? 0.75 : 0.5);
|
|
111
|
+
const after = process.memoryUsage().heapUsed / MB;
|
|
112
|
+
if (gcRan) {
|
|
113
|
+
state.reclaimedMB = round(state.reclaimedMB + Math.max(0, before - after));
|
|
114
|
+
floors.push(after);
|
|
115
|
+
if (floors.length > 12) floors.shift();
|
|
116
|
+
trackLeak();
|
|
117
|
+
}
|
|
118
|
+
if (config.log) console.log(` [Kreft GC] ${over ? 'over threshold' : 'growing ' + trend + ' MB/min'}: ${round(before)} → ${round(after)} MB.`);
|
|
119
|
+
|
|
120
|
+
if (after > config.thresholdMB * 0.9) {
|
|
121
|
+
state.overStreak++;
|
|
122
|
+
if (state.overStreak >= config.alertAfter) {
|
|
123
|
+
emitAlert('memory-high', `Memory stays high after cleaning: ${round(after)} MB (threshold ${config.thresholdMB} MB) for ${state.overStreak} checks in a row.`, { overStreak: state.overStreak });
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
state.overStreak = 0;
|
|
127
|
+
}
|
|
128
|
+
return { action: over ? 'cleanup-threshold' : 'cleanup-trend', beforeMB: round(before), afterMB: round(after), trend };
|
|
129
|
+
}
|
|
9
130
|
|
|
10
131
|
function startAutoGarbageCollector(options = {}) {
|
|
11
132
|
if (gcTimer) return;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
console.log(` [Kreft GC] Auto-collected ${reclaimedMB} MB heap memory.`);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
} catch (_) {}
|
|
42
|
-
}, intervalMs);
|
|
133
|
+
config = {
|
|
134
|
+
thresholdMB: Number(options.thresholdMB || process.env.AUTO_GC_THRESHOLD_MB) || 250,
|
|
135
|
+
intervalMs: Number(options.intervalMs || process.env.AUTO_GC_INTERVAL_MS) || 30000,
|
|
136
|
+
growthMBPerMin: Number(options.growthMBPerMin || process.env.AUTO_GC_GROWTH_MB_PER_MIN) || 20,
|
|
137
|
+
alertAfter: Number(options.alertAfter || process.env.AUTO_GC_ALERT_AFTER) || 3,
|
|
138
|
+
leakRiseMB: Number(options.leakRiseMB || process.env.AUTO_GC_LEAK_RISE_MB) || 20,
|
|
139
|
+
alertCooldownMs: Number(options.alertCooldownMs) || 10 * 60 * 1000,
|
|
140
|
+
log: options.log || false,
|
|
141
|
+
};
|
|
142
|
+
Object.assign(state, freshState(), { startedAt: new Date().toISOString() });
|
|
143
|
+
history.length = 0;
|
|
144
|
+
floors.length = 0;
|
|
145
|
+
|
|
146
|
+
// Purge migrator file caches under pressure (always present)
|
|
147
|
+
onMemoryPressure('migrator', () => {
|
|
148
|
+
const migrator = require('./migrator.js');
|
|
149
|
+
if (migrator && typeof migrator.forgetProjectFiles === 'function') migrator.forgetProjectFiles(process.cwd());
|
|
150
|
+
return 0;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const { PerformanceObserver } = require('perf_hooks');
|
|
155
|
+
observer = new PerformanceObserver((list) => { state.v8Collections += list.getEntries().length; });
|
|
156
|
+
observer.observe({ entryTypes: ['gc'] });
|
|
157
|
+
} catch (_) { observer = null; }
|
|
43
158
|
|
|
159
|
+
gcTimer = setInterval(() => { try { checkMemory(); } catch (_) {} }, config.intervalMs);
|
|
44
160
|
if (gcTimer.unref) gcTimer.unref(); // Don't hold node process open
|
|
45
161
|
}
|
|
46
162
|
|
|
47
163
|
function stopAutoGarbageCollector() {
|
|
48
|
-
if (gcTimer) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
164
|
+
if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
|
|
165
|
+
if (observer) { try { observer.disconnect(); } catch (_) {} observer = null; }
|
|
166
|
+
pressureHandlers.delete('migrator');
|
|
52
167
|
}
|
|
53
168
|
|
|
54
169
|
function getMemoryStats() {
|
|
55
170
|
const mem = process.memoryUsage();
|
|
56
171
|
return {
|
|
57
|
-
rssMB: +(mem.rss /
|
|
58
|
-
heapTotalMB: +(mem.heapTotal /
|
|
59
|
-
heapUsedMB: +(mem.heapUsed /
|
|
60
|
-
externalMB: +(mem.external /
|
|
172
|
+
rssMB: +(mem.rss / MB).toFixed(2),
|
|
173
|
+
heapTotalMB: +(mem.heapTotal / MB).toFixed(2),
|
|
174
|
+
heapUsedMB: +(mem.heapUsed / MB).toFixed(2),
|
|
175
|
+
externalMB: +(mem.external / MB).toFixed(2),
|
|
61
176
|
gcExposed: typeof global.gc === 'function',
|
|
62
177
|
};
|
|
63
178
|
}
|
|
64
179
|
|
|
180
|
+
/** Everything a dashboard or alert needs, as plain JSON. */
|
|
181
|
+
function getMemoryReport() {
|
|
182
|
+
return {
|
|
183
|
+
...getMemoryStats(),
|
|
184
|
+
running: !!gcTimer,
|
|
185
|
+
config,
|
|
186
|
+
startedAt: state.startedAt,
|
|
187
|
+
checks: state.checks,
|
|
188
|
+
cleanups: state.cleanups,
|
|
189
|
+
gcRuns: state.gcRuns,
|
|
190
|
+
v8Collections: state.v8Collections,
|
|
191
|
+
reclaimedMB: state.reclaimedMB,
|
|
192
|
+
evictedEntries: state.evicted,
|
|
193
|
+
trendMBPerMin: trendMBPerMin(),
|
|
194
|
+
overStreak: state.overStreak,
|
|
195
|
+
leak: state.leak,
|
|
196
|
+
alerts: state.alerts.slice(),
|
|
197
|
+
history: history.slice(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Writes a V8 heap snapshot (open it in Chrome DevTools → Memory) to find what is holding memory. Returns the file path. */
|
|
202
|
+
function writeHeapSnapshot(file) {
|
|
203
|
+
const path = require('path');
|
|
204
|
+
const dir = path.join(process.cwd(), 'storage', 'logs');
|
|
205
|
+
const target = file || path.join(dir, `heap-${Date.now()}.heapsnapshot`);
|
|
206
|
+
require('fs').mkdirSync(path.dirname(target), { recursive: true });
|
|
207
|
+
return require('v8').writeHeapSnapshot(target);
|
|
208
|
+
}
|
|
209
|
+
|
|
65
210
|
module.exports = {
|
|
66
211
|
startAutoGarbageCollector,
|
|
67
212
|
stopAutoGarbageCollector,
|
|
68
213
|
getMemoryStats,
|
|
214
|
+
getMemoryReport,
|
|
215
|
+
onMemoryPressure,
|
|
216
|
+
onMemoryAlert,
|
|
217
|
+
writeHeapSnapshot,
|
|
218
|
+
checkMemory,
|
|
69
219
|
};
|
package/bin/backend/index.js
CHANGED
|
@@ -453,6 +453,8 @@ module.exports = {
|
|
|
453
453
|
QR: require('./qr.js').QR,
|
|
454
454
|
...require('./gc.js'),
|
|
455
455
|
...require('./mcp.js'),
|
|
456
|
+
...require('./mcp-client.js'), // Mcp.connect(url), McpClient
|
|
457
|
+
...require('./shield.js'), // Shield: SHIELD=true in .env limits and bans abusive addresses
|
|
456
458
|
...require('./config.js'), // config, Config, ConfigRepository
|
|
457
459
|
...require('./html.js'), // cleanHtml, sanitizeHtml, stripTags, escapeHtml, e, unescapeHtml, SafeHtml, html
|
|
458
460
|
// for the kreftjs command
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MCP client (Streamable HTTP): call tools on a remote MCP server from your app.
|
|
3
|
+
*
|
|
4
|
+
* const { Mcp } = require('kreftjs/server')
|
|
5
|
+
* const quran = await Mcp.connect('https://mcp.quran.ai')
|
|
6
|
+
* const tools = await quran.tools()
|
|
7
|
+
* const out = await quran.call(tools[0].name, { ... })
|
|
8
|
+
* await quran.close()
|
|
9
|
+
*
|
|
10
|
+
* Options: { headers: { Authorization: 'Bearer …' }, timeout: 30000, maxBytes: 10485760 }
|
|
11
|
+
*
|
|
12
|
+
* Security: only http(s) URLs; redirects are refused (a redirect could carry your Authorization header to another
|
|
13
|
+
* host); a response over maxBytes is cut off. Never pass a URL a visitor typed: the server would then fetch
|
|
14
|
+
* addresses of your choosing for them (SSRF), including your internal network. Use a fixed or allow-listed URL.
|
|
15
|
+
*/
|
|
16
|
+
class McpClient {
|
|
17
|
+
constructor(url, options = {}) {
|
|
18
|
+
this.url = url;
|
|
19
|
+
this.headers = options.headers || {};
|
|
20
|
+
this.timeout = options.timeout || 30000;
|
|
21
|
+
this.maxBytes = options.maxBytes || 10 * 1024 * 1024;
|
|
22
|
+
let u;
|
|
23
|
+
try { u = new URL(url); } catch (_) { throw new Error('MCP: not a valid URL: ' + url); }
|
|
24
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('MCP: only http:// and https:// URLs are allowed');
|
|
25
|
+
this.sessionId = null;
|
|
26
|
+
this.server = null;
|
|
27
|
+
this._id = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async _post(body) {
|
|
31
|
+
const headers = {
|
|
32
|
+
'content-type': 'application/json',
|
|
33
|
+
accept: 'application/json, text/event-stream',
|
|
34
|
+
...this.headers,
|
|
35
|
+
};
|
|
36
|
+
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
|
|
37
|
+
const res = await fetch(this.url, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers,
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
42
|
+
redirect: 'error',
|
|
43
|
+
});
|
|
44
|
+
const sid = res.headers.get('mcp-session-id');
|
|
45
|
+
if (sid) this.sessionId = sid;
|
|
46
|
+
if (!res.ok) throw new Error(`MCP ${this.url} answered ${res.status}: ${(await this._readText(res).catch(() => '')).slice(0, 200)}`);
|
|
47
|
+
if (body.id === undefined) return null; // notification
|
|
48
|
+
const text = await this._readText(res);
|
|
49
|
+
const type = res.headers.get('content-type') || '';
|
|
50
|
+
let msg = null;
|
|
51
|
+
if (type.includes('text/event-stream')) {
|
|
52
|
+
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
53
|
+
const data = block.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
|
|
54
|
+
if (!data) continue;
|
|
55
|
+
try {
|
|
56
|
+
const m = JSON.parse(data);
|
|
57
|
+
if (m.id === body.id) { msg = m; break; }
|
|
58
|
+
} catch { /* skip non-JSON events */ }
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
msg = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
if (!msg) throw new Error('MCP: no response for ' + body.method);
|
|
64
|
+
if (msg.error) throw new Error(`MCP ${body.method}: ${msg.error.message || JSON.stringify(msg.error)}`);
|
|
65
|
+
return msg.result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// reads the body but stops at maxBytes, so a hostile or broken server cannot fill the memory
|
|
69
|
+
async _readText(res) {
|
|
70
|
+
const len = Number(res.headers.get('content-length'));
|
|
71
|
+
if (len > this.maxBytes) throw new Error(`MCP: the response is larger than ${this.maxBytes} bytes`);
|
|
72
|
+
if (!res.body) return '';
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let size = 0;
|
|
75
|
+
for await (const chunk of res.body) {
|
|
76
|
+
size += chunk.length;
|
|
77
|
+
if (size > this.maxBytes) { try { await res.body.cancel(); } catch (_) {} throw new Error(`MCP: the response is larger than ${this.maxBytes} bytes`); }
|
|
78
|
+
chunks.push(chunk);
|
|
79
|
+
}
|
|
80
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
rpc(method, params = {}) {
|
|
84
|
+
return this._post({ jsonrpc: '2.0', id: ++this._id, method, params });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async connect() {
|
|
88
|
+
const result = await this.rpc('initialize', {
|
|
89
|
+
protocolVersion: '2025-03-26',
|
|
90
|
+
capabilities: {},
|
|
91
|
+
clientInfo: { name: 'kreftjs', version: require('../../package.json').version },
|
|
92
|
+
});
|
|
93
|
+
this.server = result;
|
|
94
|
+
await this._post({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async tools() {
|
|
99
|
+
const all = [];
|
|
100
|
+
let cursor;
|
|
101
|
+
do {
|
|
102
|
+
const r = await this.rpc('tools/list', cursor ? { cursor } : {});
|
|
103
|
+
all.push(...(r.tools || []));
|
|
104
|
+
cursor = r.nextCursor;
|
|
105
|
+
} while (cursor);
|
|
106
|
+
return all;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Calls a tool. Returns structuredContent when present, else the joined text, else the raw result. */
|
|
110
|
+
async call(name, args = {}) {
|
|
111
|
+
const r = await this.rpc('tools/call', { name, arguments: args });
|
|
112
|
+
if (r.isError) throw new Error(`MCP tool ${name} failed: ${(r.content || []).map((c) => c.text).join(' ')}`);
|
|
113
|
+
if (r.structuredContent) return r.structuredContent;
|
|
114
|
+
const text = (r.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
|
115
|
+
if (!text) return r;
|
|
116
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async resources() { return (await this.rpc('resources/list')).resources || []; }
|
|
120
|
+
async readResource(uri) { return this.rpc('resources/read', { uri }); }
|
|
121
|
+
|
|
122
|
+
async close() {
|
|
123
|
+
if (!this.sessionId) return;
|
|
124
|
+
try { await fetch(this.url, { method: 'DELETE', headers: { ...this.headers, 'mcp-session-id': this.sessionId }, signal: AbortSignal.timeout(5000), redirect: 'error' }); } catch { /* server may not support it */ }
|
|
125
|
+
this.sessionId = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const Mcp = {
|
|
130
|
+
connect: (url, options) => new McpClient(url, options).connect(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
module.exports = { McpClient, Mcp };
|
package/bin/backend/mcp.js
CHANGED
|
@@ -159,6 +159,23 @@ class McpServer {
|
|
|
159
159
|
};
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
// 3b. Tool: memory_report - GC runs, evictions, trend, leak suspicion, alerts, recent samples
|
|
163
|
+
this.registerTool('memory_report', {
|
|
164
|
+
description: 'Memory Manager report: heap trend, GC and cache-eviction counts, leak suspicion, recent alerts and samples.',
|
|
165
|
+
inputSchema: { type: 'object', properties: {} },
|
|
166
|
+
}, async () => ({
|
|
167
|
+
content: [{ type: 'text', text: JSON.stringify(require('./gc.js').getMemoryReport(), null, 2) }],
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
// 3c. Tool: shield_status - the abuse shield: banned addresses, counters, limits
|
|
171
|
+
this.registerTool('shield_status', {
|
|
172
|
+
description: 'Kreft Shield (SHIELD=true): whether it is on, its limits, counters, event-loop lag and the currently banned addresses.',
|
|
173
|
+
inputSchema: { type: 'object', properties: {} },
|
|
174
|
+
}, async () => {
|
|
175
|
+
const { Shield } = require('./shield.js');
|
|
176
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...Shield.status(), bans: Shield.bans() }, null, 2) }] };
|
|
177
|
+
});
|
|
178
|
+
|
|
162
179
|
// 4. Resource: kreft://routes
|
|
163
180
|
const routesResource = async () => {
|
|
164
181
|
const { Route } = require('./index.js');
|
package/bin/backend/model.js
CHANGED
|
@@ -255,6 +255,17 @@ class ModelBuilder extends Builder {
|
|
|
255
255
|
async restore() { const M = this._model; const b = this.clone(); b._trashed = 'only'; return Builder.prototype.update.call(b, { [M.deletedAtColumn]: null }); }
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// A model with no `static fillable` lets create()/update() set every column but `id`. If the data came from a request,
|
|
259
|
+
// a visitor could send { "is_admin": true } or { "role": "admin" }. Say so once per model and column.
|
|
260
|
+
const PRIVILEGE_KEY_RE = /^(is_?admin|is_?super_?user|is_?staff|is_?owner|role|roles|role_?id|permissions?|remember_token|api_token|email_verified_at)$/i;
|
|
261
|
+
const warnedFill = new Set();
|
|
262
|
+
function warnPrivilegeFill(C, key) {
|
|
263
|
+
const id = (C.name || C.table) + '.' + key;
|
|
264
|
+
if (warnedFill.has(id)) return;
|
|
265
|
+
warnedFill.add(id);
|
|
266
|
+
console.warn(` [Kreft Security] ${C.name || C.table}.${key} is being mass-assigned and the model has no static fillable. If this value can come from a request, a visitor could set it themselves. List the allowed columns in static fillable (or add '${key}' to static guarded); use forceFill() where you really mean it.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
258
269
|
/* ------------------------------------------------------------------ the model */
|
|
259
270
|
class Model {
|
|
260
271
|
// ---- settings a model can change
|
|
@@ -415,7 +426,10 @@ class Model {
|
|
|
415
426
|
for (const [k, v] of Object.entries(attributes)) {
|
|
416
427
|
if (BAD_KEYS.has(k)) continue;
|
|
417
428
|
const allowed = C.fillable ? C.fillable.includes(k) : !(C.guarded || []).includes(k) && !(C.guarded || []).includes('*');
|
|
418
|
-
if (allowed)
|
|
429
|
+
if (allowed) {
|
|
430
|
+
if (!C.fillable && PRIVILEGE_KEY_RE.test(k)) warnPrivilegeFill(C, k);
|
|
431
|
+
this.setAttribute(k, v);
|
|
432
|
+
}
|
|
419
433
|
}
|
|
420
434
|
return this;
|
|
421
435
|
}
|