kreftjs 0.0.21-beta → 0.0.22-beta
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/db-api.js +9 -1
- package/bin/kreftjs.js +15 -1
- package/docs/README.md +100 -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 +2 -2
- 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 +2 -2
- package/book/~$eft-book-ar.docx +0 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Kreft Shield: automatic protection against floods and abuse from a single address.
|
|
3
|
+
*
|
|
4
|
+
* SHIELD=true in .env turns it on (off by default). Changes to .env apply without a restart.
|
|
5
|
+
*
|
|
6
|
+
* What it does, per visitor address (the same req.ip() that throttle uses, so TRUSTED_PROXIES applies):
|
|
7
|
+
* 1. Rate limit: more than SHIELD_MAX_REQUESTS (300) in SHIELD_WINDOW_SEC (10) seconds → 429 + Retry-After.
|
|
8
|
+
* Responses that look like probing (401, 403, 404, 405) count SHIELD_ERROR_WEIGHT (5) times, so scanners
|
|
9
|
+
* and password guessers hit the limit sooner than normal visitors.
|
|
10
|
+
* 2. Concurrency: more than SHIELD_MAX_CONCURRENT (60) requests of one address in flight at once → 429.
|
|
11
|
+
* 3. Ban: SHIELD_STRIKES (3) rate-limit hits within SHIELD_STRIKE_WINDOW_MIN (10) minutes → the address is
|
|
12
|
+
* refused for SHIELD_BAN_MINUTES (15), doubling at every repeat (up to 24 hours). Banned requests are
|
|
13
|
+
* answered with a bare 429 before any work is done.
|
|
14
|
+
* 4. Connections: more than SHIELD_MAX_CONNECTIONS (100) open sockets from one address are closed at once, which
|
|
15
|
+
* stops slow-connection floods (slowloris) that never finish a request. Needs Shield.watch(server) (kreft does it).
|
|
16
|
+
* 5. Overload: when the event loop lags by more than SHIELD_MAX_LAG_MS (300), limits shrink to a quarter
|
|
17
|
+
* so the server keeps answering instead of falling over.
|
|
18
|
+
*
|
|
19
|
+
* Never limited: SHIELD_ALLOW="1.2.3.4,5.6.7.8" and, unless SHIELD_LOCAL=true, this computer (127.0.0.1, ::1).
|
|
20
|
+
* Bans live in memory: a restart clears them. For a flood bigger than one process can absorb, put a firewall
|
|
21
|
+
* or CDN in front; the Shield protects the application, it does not replace network-level DDoS protection.
|
|
22
|
+
*
|
|
23
|
+
* const { Shield } = require('kreftjs/server')
|
|
24
|
+
* Shield.bans() Shield.unban('1.2.3.4') Shield.ban('1.2.3.4', 60) Shield.status() Shield.on('ban', fn)
|
|
25
|
+
*/
|
|
26
|
+
const { EventEmitter } = require('events');
|
|
27
|
+
|
|
28
|
+
const events = new EventEmitter();
|
|
29
|
+
const ips = new Map(); // address → { windowStart, count, struck, strikes: [ms], level, lastBanAt, bannedUntil, inflight, last }
|
|
30
|
+
const MAX_ENTRIES = 100000;
|
|
31
|
+
const DAY = 86400000;
|
|
32
|
+
|
|
33
|
+
let cfgAt = 0;
|
|
34
|
+
let cfg = null;
|
|
35
|
+
let lagMonitor = null;
|
|
36
|
+
let lagMs = 0;
|
|
37
|
+
let timers = [];
|
|
38
|
+
let warnedProxy = false;
|
|
39
|
+
const conns = new Map(); // direct socket address → open connections
|
|
40
|
+
const stats = { blocked: 0, limited: 0, bans: 0 };
|
|
41
|
+
|
|
42
|
+
const num = (v, d) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
|
|
43
|
+
const flag = (v) => ['true', '1', 'on', 'yes'].includes(String(v || '').trim().toLowerCase());
|
|
44
|
+
|
|
45
|
+
function config() {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
if (cfg && now - cfgAt < 2000) return cfg;
|
|
48
|
+
const e = process.env;
|
|
49
|
+
cfg = {
|
|
50
|
+
enabled: flag(e.SHIELD),
|
|
51
|
+
maxRequests: num(e.SHIELD_MAX_REQUESTS, 300),
|
|
52
|
+
windowMs: num(e.SHIELD_WINDOW_SEC, 10) * 1000,
|
|
53
|
+
errorWeight: num(e.SHIELD_ERROR_WEIGHT, 5),
|
|
54
|
+
maxConcurrent: num(e.SHIELD_MAX_CONCURRENT, 60),
|
|
55
|
+
maxConnections: num(e.SHIELD_MAX_CONNECTIONS, 100),
|
|
56
|
+
strikes: num(e.SHIELD_STRIKES, 3),
|
|
57
|
+
strikeWindowMs: num(e.SHIELD_STRIKE_WINDOW_MIN, 10) * 60000,
|
|
58
|
+
banMs: num(e.SHIELD_BAN_MINUTES, 15) * 60000,
|
|
59
|
+
maxLagMs: num(e.SHIELD_MAX_LAG_MS, 300),
|
|
60
|
+
local: flag(e.SHIELD_LOCAL),
|
|
61
|
+
allow: new Set(String(e.SHIELD_ALLOW || '').split(',').map((x) => x.trim().replace(/^::ffff:/, '')).filter(Boolean)),
|
|
62
|
+
};
|
|
63
|
+
cfgAt = now;
|
|
64
|
+
return cfg;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
68
|
+
const normalize = (ip) => String(ip || '').replace(/^::ffff:/, '');
|
|
69
|
+
|
|
70
|
+
// One IPv6 customer owns a whole /64 (billions of addresses): counting single addresses would let an attacker
|
|
71
|
+
// rotate through them and never be limited. IPv6 visitors are counted by their /64 prefix, IPv4 by address.
|
|
72
|
+
function keyOf(ip) {
|
|
73
|
+
if (!ip.includes(':')) return ip;
|
|
74
|
+
const s = ip.split('%')[0];
|
|
75
|
+
if (s.includes('.')) return ip; // an embedded IPv4 form: leave as is
|
|
76
|
+
const dbl = s.includes('::');
|
|
77
|
+
const [h, t] = s.split('::');
|
|
78
|
+
const head = h ? h.split(':') : [];
|
|
79
|
+
const tail = dbl && t ? t.split(':') : [];
|
|
80
|
+
const fill = dbl ? 8 - head.length - tail.length : 0;
|
|
81
|
+
if (fill < 0 || (!dbl && head.length !== 8)) return ip;
|
|
82
|
+
const groups = [...head, ...Array(fill).fill('0'), ...tail].map((x) => parseInt(x, 16));
|
|
83
|
+
if (groups.length !== 8 || groups.some((x) => Number.isNaN(x))) return ip;
|
|
84
|
+
return groups.slice(0, 4).map((x) => x.toString(16)).join(':') + '::/64';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function entry(ip, now) {
|
|
88
|
+
let e = ips.get(ip);
|
|
89
|
+
if (!e) {
|
|
90
|
+
if (ips.size >= MAX_ENTRIES) prune(true);
|
|
91
|
+
e = { windowStart: now, count: 0, struck: false, strikes: [], level: 0, lastBanAt: 0, bannedUntil: 0, inflight: 0, last: now };
|
|
92
|
+
ips.set(ip, e);
|
|
93
|
+
}
|
|
94
|
+
e.last = now;
|
|
95
|
+
return e;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Drops addresses that are idle and not banned. `hard` also drops the oldest when the table is full.
|
|
99
|
+
function prune(hard = false) {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
for (const [ip, e] of ips) {
|
|
102
|
+
if (e.bannedUntil > now || e.inflight > 0) continue;
|
|
103
|
+
if (now - e.last > 10 * 60000 && (!e.lastBanAt || now - e.lastBanAt > DAY)) ips.delete(ip);
|
|
104
|
+
}
|
|
105
|
+
if (hard && ips.size >= MAX_ENTRIES) {
|
|
106
|
+
for (const [ip, e] of ips) { if (e.bannedUntil <= now && e.inflight === 0) { ips.delete(ip); if (ips.size < MAX_ENTRIES * 0.9) break; } }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ensureRunning() {
|
|
111
|
+
if (timers.length) return;
|
|
112
|
+
try {
|
|
113
|
+
const { monitorEventLoopDelay } = require('perf_hooks');
|
|
114
|
+
lagMonitor = monitorEventLoopDelay({ resolution: 20 });
|
|
115
|
+
lagMonitor.enable();
|
|
116
|
+
} catch (_) { lagMonitor = null; }
|
|
117
|
+
const t1 = setInterval(() => {
|
|
118
|
+
if (lagMonitor) { lagMs = lagMonitor.percentile(99) / 1e6; lagMonitor.reset(); }
|
|
119
|
+
const c = config();
|
|
120
|
+
if (c.enabled && lagMs > c.maxLagMs && !ensureRunning.warned) {
|
|
121
|
+
ensureRunning.warned = true;
|
|
122
|
+
console.warn(` [Kreft Shield] The server is overloaded (event loop lag ${Math.round(lagMs)} ms): per-address limits are cut to a quarter.`);
|
|
123
|
+
} else if (lagMs <= c.maxLagMs) ensureRunning.warned = false;
|
|
124
|
+
}, 5000);
|
|
125
|
+
const t2 = setInterval(() => prune(), 60000);
|
|
126
|
+
timers = [t1, t2];
|
|
127
|
+
timers.forEach((t) => t.unref && t.unref());
|
|
128
|
+
try { require('./gc.js').onMemoryPressure('shield', () => { const before = ips.size; prune(true); return before - ips.size; }); } catch (_) {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function banEntry(ip, e, ms, reason) {
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
e.bannedUntil = now + ms;
|
|
134
|
+
e.lastBanAt = now;
|
|
135
|
+
e.level++;
|
|
136
|
+
e.strikes = [];
|
|
137
|
+
stats.bans++;
|
|
138
|
+
const info = { ip, minutes: Math.round(ms / 60000), reason, until: new Date(e.bannedUntil).toISOString(), level: e.level };
|
|
139
|
+
console.warn(` [Kreft Shield] Banned ${ip} for ${info.minutes} min (${reason}).`);
|
|
140
|
+
events.emit('ban', info);
|
|
141
|
+
return info;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function refuse(res, seconds, why) {
|
|
145
|
+
stats.blocked++;
|
|
146
|
+
try {
|
|
147
|
+
res.writeHead(429, { 'Retry-After': String(Math.max(1, Math.ceil(seconds))), 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', Connection: 'close' });
|
|
148
|
+
res.end(why);
|
|
149
|
+
} catch (_) { try { res.destroy(); } catch (__) {} }
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Call first for every request. Returns true when it already answered (the request must stop there).
|
|
155
|
+
*/
|
|
156
|
+
function guard(req, res) {
|
|
157
|
+
const c = config();
|
|
158
|
+
if (!c.enabled) return false;
|
|
159
|
+
ensureRunning();
|
|
160
|
+
let ip;
|
|
161
|
+
try { ip = normalize(require('./http.js').Request.prototype.ip.call({ raw: req })); } catch (_) { ip = normalize(req.socket && req.socket.remoteAddress); }
|
|
162
|
+
if (!ip) return false;
|
|
163
|
+
// Behind a proxy that is not in TRUSTED_PROXIES every visitor looks like the proxy: one address (all users would
|
|
164
|
+
// share one limit and one ban) or, for a proxy on this computer, loopback (which is never limited). Say so once.
|
|
165
|
+
if (!warnedProxy && req.headers['x-forwarded-for'] && ip === normalize(req.socket && req.socket.remoteAddress)) {
|
|
166
|
+
warnedProxy = true;
|
|
167
|
+
console.warn(` [Kreft Shield] Requests carry X-Forwarded-For, but ${ip} is not in TRUSTED_PROXIES, so the real visitor address is unknown and the Shield cannot tell visitors apart. Behind nginx, Caddy or a CDN, set TRUSTED_PROXIES in .env.`);
|
|
168
|
+
}
|
|
169
|
+
if (c.allow.has(ip) || (isLoopback(ip) && !c.local)) return false;
|
|
170
|
+
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const key = keyOf(ip);
|
|
173
|
+
const e = entry(key, now);
|
|
174
|
+
|
|
175
|
+
if (e.bannedUntil > now) return refuse(res, (e.bannedUntil - now) / 1000, 'Too many requests. Try again later.');
|
|
176
|
+
if (e.bannedUntil && e.bannedUntil <= now) e.bannedUntil = 0;
|
|
177
|
+
if (e.level && e.lastBanAt && now - e.lastBanAt > DAY) e.level = 0;
|
|
178
|
+
|
|
179
|
+
if (now - e.windowStart >= c.windowMs) { e.windowStart = now; e.count = 0; e.struck = false; }
|
|
180
|
+
e.strikes = e.strikes.filter((t) => now - t < c.strikeWindowMs);
|
|
181
|
+
|
|
182
|
+
const overloaded = lagMs > c.maxLagMs;
|
|
183
|
+
const limit = Math.max(5, Math.floor(c.maxRequests / (overloaded ? 4 : 1)));
|
|
184
|
+
const concurrent = Math.max(3, Math.floor(c.maxConcurrent / (overloaded ? 4 : 1)));
|
|
185
|
+
e.count++;
|
|
186
|
+
|
|
187
|
+
const overRate = e.count > limit;
|
|
188
|
+
const overConc = e.inflight >= concurrent;
|
|
189
|
+
if (overRate || overConc) {
|
|
190
|
+
stats.limited++;
|
|
191
|
+
if (!e.struck) {
|
|
192
|
+
e.struck = true;
|
|
193
|
+
e.strikes.push(now);
|
|
194
|
+
if (e.strikes.length >= c.strikes) {
|
|
195
|
+
const ms = Math.min(DAY, c.banMs * 2 ** e.level);
|
|
196
|
+
banEntry(key, e, ms, `${e.strikes.length} rate-limit hits in ${Math.round(c.strikeWindowMs / 60000)} min`);
|
|
197
|
+
return refuse(res, ms / 1000, 'Too many requests. Try again later.');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return refuse(res, (e.windowStart + c.windowMs - now) / 1000, 'Too many requests. Slow down.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
e.inflight++;
|
|
204
|
+
let done = false;
|
|
205
|
+
const finish = () => { if (done) return; done = true; e.inflight = Math.max(0, e.inflight - 1); };
|
|
206
|
+
res.once('close', finish);
|
|
207
|
+
res.once('finish', () => {
|
|
208
|
+
// probing (wrong login, missing pages, forbidden) costs more, so scanners trip the limit early
|
|
209
|
+
const s = res.statusCode;
|
|
210
|
+
if (s === 401 || s === 403 || s === 404 || s === 405) e.count += c.errorWeight - 1;
|
|
211
|
+
finish();
|
|
212
|
+
});
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const Shield = {
|
|
217
|
+
guard,
|
|
218
|
+
enabled: () => config().enabled,
|
|
219
|
+
on: (event, fn) => { events.on(event, fn); return Shield; },
|
|
220
|
+
off: (event, fn) => { events.off(event, fn); return Shield; },
|
|
221
|
+
/**
|
|
222
|
+
* Caps open connections per direct address (call once on the http.Server). Connections from this computer or from a
|
|
223
|
+
* proxy in TRUSTED_PROXIES are not capped here: they carry many visitors, and the request limits tell those apart.
|
|
224
|
+
*/
|
|
225
|
+
watch(server) {
|
|
226
|
+
server.on('connection', (sock) => {
|
|
227
|
+
const c = config();
|
|
228
|
+
if (!c.enabled) return;
|
|
229
|
+
const ip = normalize(sock.remoteAddress);
|
|
230
|
+
const trusted = String(process.env.TRUSTED_PROXIES || '').split(',').map((x) => normalize(x.trim())).filter(Boolean);
|
|
231
|
+
if (!ip || c.allow.has(ip) || (isLoopback(ip) && !c.local) || trusted.includes('*') || trusted.includes(ip)) return;
|
|
232
|
+
const key = keyOf(ip);
|
|
233
|
+
const n = (conns.get(key) || 0) + 1;
|
|
234
|
+
if (n > c.maxConnections) { stats.blocked++; sock.destroy(); return; }
|
|
235
|
+
conns.set(key, n);
|
|
236
|
+
sock.once('close', () => { const m = (conns.get(key) || 1) - 1; if (m <= 0) conns.delete(key); else conns.set(key, m); });
|
|
237
|
+
});
|
|
238
|
+
return Shield;
|
|
239
|
+
},
|
|
240
|
+
/** Currently banned addresses. */
|
|
241
|
+
bans() {
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
const out = [];
|
|
244
|
+
for (const [ip, e] of ips) if (e.bannedUntil > now) out.push({ ip, until: new Date(e.bannedUntil).toISOString(), level: e.level, secondsLeft: Math.ceil((e.bannedUntil - now) / 1000) });
|
|
245
|
+
return out;
|
|
246
|
+
},
|
|
247
|
+
ban(ip, minutes = 60, reason = 'manual') { const a = keyOf(normalize(ip)); return banEntry(a, entry(a, Date.now()), minutes * 60000, reason); },
|
|
248
|
+
unban(ip) {
|
|
249
|
+
const e = ips.get(keyOf(normalize(ip)));
|
|
250
|
+
if (!e) return false;
|
|
251
|
+
e.bannedUntil = 0; e.level = 0; e.strikes = []; e.count = 0; e.struck = false;
|
|
252
|
+
return true;
|
|
253
|
+
},
|
|
254
|
+
status() {
|
|
255
|
+
const c = config();
|
|
256
|
+
return { enabled: c.enabled, tracked: ips.size, banned: Shield.bans().length, ...stats, eventLoopLagMs: Math.round(lagMs), overloaded: lagMs > c.maxLagMs, limits: { maxRequests: c.maxRequests, windowSec: c.windowMs / 1000, maxConcurrent: c.maxConcurrent, strikes: c.strikes, banMinutes: c.banMs / 60000 } };
|
|
257
|
+
},
|
|
258
|
+
/** For tests: forget everything and stop the timers. */
|
|
259
|
+
reset() {
|
|
260
|
+
ips.clear(); conns.clear(); warnedProxy = false; cfg = null; lagMs = 0; stats.blocked = stats.limited = stats.bans = 0;
|
|
261
|
+
timers.forEach(clearInterval); timers = [];
|
|
262
|
+
if (lagMonitor) { try { lagMonitor.disable(); } catch (_) {} lagMonitor = null; }
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
module.exports = { Shield, _keyOf: keyOf };
|
package/bin/db-api.js
CHANGED
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
* Which tables, and what each allows, comes from `tables` (kreftjs.config.json):
|
|
17
17
|
* { "posts": true, "users": { "write": false, "hidden": ["password"] }, "logs": false }
|
|
18
18
|
* `kreft dev` shows every table unless you list them; `kreft serve` shows only the tables you list.
|
|
19
|
+
* Credential-looking columns (password, token, secret, hash, …) are hidden by default ("show": ["token"] opens one), and
|
|
20
|
+
* tables of sessions, tokens and jobs are only opened when named in the list.
|
|
19
21
|
*/
|
|
20
22
|
const { validate } = require('../kreftjs.js'); // the same rules as Kreftjs.validate in the browser
|
|
21
23
|
const MAX_BODY = 1024 * 1024;
|
|
22
24
|
const MAX_ROWS = 1000;
|
|
25
|
+
const SECRET_COL_RE = /(password|passwd|secret|token|api_?key|private_?key|recovery_?code|remember|two_factor|salt|hash)/i;
|
|
26
|
+
const SECRET_TABLE_RE = /^(personal_access_tokens|password_reset_tokens|password_resets|sessions|failed_jobs|jobs|job_batches|cache|cache_locks)$/i;
|
|
23
27
|
|
|
24
28
|
function createDbApi(db, { tables: allow = null, production = false, prefix = '/api', log = console } = {}) {
|
|
25
29
|
const isProd = Boolean(production || process.env.APP_ENV === 'production' || process.env.NODE_ENV === 'production');
|
|
@@ -29,13 +33,17 @@ function createDbApi(db, { tables: allow = null, production = false, prefix = '/
|
|
|
29
33
|
const out = new Map();
|
|
30
34
|
for (const t of all) {
|
|
31
35
|
if (t.name === 'kreft_migrations') continue;
|
|
36
|
+
// tables that hold logins, tokens or queued payloads are never shown by default, even by `kreft dev` with no list:
|
|
37
|
+
// only a table named in kreftjs.config.json is opened
|
|
38
|
+
if (SECRET_TABLE_RE.test(t.name) && !(allow && allow[t.name] != null && allow[t.name] !== false)) continue;
|
|
32
39
|
let rules = allow ? allow[t.name] : true;
|
|
33
40
|
if (rules == null || rules === false) continue;
|
|
34
41
|
if (rules === true) rules = {};
|
|
35
42
|
const write = rules.write === undefined ? true : rules.write;
|
|
36
43
|
out.set(t.name, {
|
|
37
44
|
...t,
|
|
38
|
-
hidden:
|
|
45
|
+
// columns that look like credentials are hidden unless "show": ["token"] lists them (a "hidden" list adds to them)
|
|
46
|
+
hidden: new Set([...t.columns.map((c) => c.name).filter((n) => SECRET_COL_RE.test(n) && !(rules.show || []).includes(n)), ...(rules.hidden || [])]),
|
|
39
47
|
rules: rules.rules || null, // { "title": "required|min:3", "email": "required|email" }
|
|
40
48
|
can: {
|
|
41
49
|
read: rules.read !== false,
|
package/bin/kreftjs.js
CHANGED
|
@@ -640,7 +640,7 @@ function installVscode() {
|
|
|
640
640
|
/* ------------------------------------------------------------------ kreft dev */
|
|
641
641
|
async function devServer(args, { production = false } = {}) {
|
|
642
642
|
const isProd = Boolean(production || process.env.APP_ENV === 'production' || process.env.NODE_ENV === 'production');
|
|
643
|
-
let port = production ? Number(process.env.PORT) || 8000 : 8000, folder = production ? 'dist' : '.', apiFile = null, delay = 0, saveApi = true, hot = !production, dbUrl = null, migrations = null, serverFolder = null, backendFolder = null, seedNew = false;
|
|
643
|
+
let port = production ? Number(process.env.PORT) || 8000 : 8000, folder = production ? 'dist' : '.', apiFile = null, delay = 0, saveApi = true, hot = !production, dbUrl = null, migrations = null, serverFolder = null, backendFolder = null, seedNew = false, force = false;
|
|
644
644
|
// kreft dev only answers this computer; --host 0.0.0.0 shares it on your network.
|
|
645
645
|
// kreft serve answers everyone (HOST in the environment can narrow it).
|
|
646
646
|
let host = production ? process.env.HOST || '0.0.0.0' : '127.0.0.1';
|
|
@@ -655,6 +655,7 @@ async function devServer(args, { production = false } = {}) {
|
|
|
655
655
|
else if (args[i] === '--server') serverFolder = path.resolve(args[++i]);
|
|
656
656
|
else if (args[i] === '--backend') backendFolder = path.resolve(args[++i]);
|
|
657
657
|
else if (args[i] === '--seed') seedNew = true;
|
|
658
|
+
else if (args[i] === '--force') force = true;
|
|
658
659
|
else if (args[i] === '--host') host = args[++i];
|
|
659
660
|
else if (/^\d+$/.test(args[i])) port = Number(args[i]);
|
|
660
661
|
else folder = args[i];
|
|
@@ -680,6 +681,16 @@ async function devServer(args, { production = false } = {}) {
|
|
|
680
681
|
const backendDir = backendFolder || (lay.split ? lay.backend : envDir);
|
|
681
682
|
const apiRoutesFile = require('./backend/typescript.js').findFile(path.join(backendDir, 'routes', 'api')); // api.js or api.ts
|
|
682
683
|
const hasBackend = Boolean(apiRoutesFile);
|
|
684
|
+
// Not a Kreft project and nothing to show: do not start a server on whatever folder this happens to be (an empty
|
|
685
|
+
// one, or your home folder). --force serves it anyway.
|
|
686
|
+
if (!force && !dbUrl && !apiFile && !serverFolder && !hasBackend && !lay.split) {
|
|
687
|
+
const names = (() => { try { return fs.readdirSync(root); } catch (_) { return []; } })();
|
|
688
|
+
const isSite = names.includes('index.html') || names.some((n) => /\.kreft$/.test(n)) || fs.existsSync(path.join(root, 'demo', 'index.html')) || fs.existsSync(path.join(config.dir || root, 'kreftjs.config.json'));
|
|
689
|
+
if (!isSite) {
|
|
690
|
+
console.error(` ${path.resolve(root)} is not a Kreft project: there is no index.html, no frontend/ folder and no .kreft pages.\n\n Make one: kreft new my-app (then: cd my-app, kreft dev)\n Or go into your project: cd path/to/your-app\n To serve this folder anyway: kreft ${production ? 'serve' : 'dev'} ${folder === '.' ? '' : folder + ' '}--force`);
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
683
694
|
// kreftjs serve: the backend must not sit inside the folder that is served, or its files could be downloaded
|
|
684
695
|
if (production && hasBackend && (backendDir === root || backendDir.startsWith(root + path.sep))) {
|
|
685
696
|
console.error(` The backend (${backendDir}) is inside the folder being served (${root}).\n Serve the built site instead: kreft build, then kreft serve dist`);
|
|
@@ -1060,6 +1071,8 @@ async function devServer(args, { production = false } = {}) {
|
|
|
1060
1071
|
|
|
1061
1072
|
const server = http.createServer(async (req, res) => {
|
|
1062
1073
|
try {
|
|
1074
|
+
// SHIELD=true in .env: rate limits, bans and load shedding per visitor address (see backend/shield.js)
|
|
1075
|
+
if (require('./backend/shield.js').Shield.guard(req, res)) return;
|
|
1063
1076
|
if (loopbackBind && !isLocalName(hostOnly(req.headers.host)) && !allowedHosts.has(hostOnly(req.headers.host))) {
|
|
1064
1077
|
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1065
1078
|
return res.end(`Blocked: this server only answers localhost. To reach it by the name "${hostOnly(req.headers.host)}", start it with KREFT_ALLOWED_HOSTS=${hostOnly(req.headers.host)} (or --host 0.0.0.0 to share it on your network).`);
|
|
@@ -1177,6 +1190,7 @@ async function devServer(args, { production = false } = {}) {
|
|
|
1177
1190
|
|
|
1178
1191
|
// if the port is taken (another server is still running), try the next one
|
|
1179
1192
|
let tries = 0;
|
|
1193
|
+
require('./backend/shield.js').Shield.watch(server); // SHIELD=true: caps open connections per address
|
|
1180
1194
|
server.on('error', (err) => {
|
|
1181
1195
|
if (err.code === 'EADDRINUSE' && tries < 20) {
|
|
1182
1196
|
console.log(` Port ${port + tries} is already in use by another program, trying ${port + tries + 1}…`);
|
package/docs/README.md
CHANGED
|
@@ -82,7 +82,7 @@ npx kreftjs@latest new my-app
|
|
|
82
82
|
### Check that it works
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
|
-
kreft --version # for example 0.0.
|
|
85
|
+
kreft --version # for example 0.0.22-beta
|
|
86
86
|
kreft about # Kreft and Node.js versions, this project's settings and drivers
|
|
87
87
|
kreft list # every command your version has
|
|
88
88
|
```
|
|
@@ -1291,10 +1291,36 @@ Generate one with `kreft make:controller PostController --api` (also `--model Po
|
|
|
1291
1291
|
|---|---|
|
|
1292
1292
|
| `req.input('name', default)`, `req.all()`, `req.only('a', 'b')`, `req.except(…)`, `req.has(…)`, `req.filled(…)` | Values from the body and the query. |
|
|
1293
1293
|
| `req.boolean('x')`, `req.integer('page', 1)`, `req.string('q')` | A value in the right type. |
|
|
1294
|
-
| `await req.validate(rules)` | The same rules as `form()`, plus `unique:table,column` and `exists:table,column`. Returns only the checked fields, or answers `422`. |
|
|
1294
|
+
| `await req.validate(rules, messages, names)` | The same rules as `form()`, plus `unique:table,column` and `exists:table,column`. Returns only the checked fields, or answers `422`. `messages` are your own texts (`{ 'title.unique': 'That title is already used.' }`, or `{ unique: '…' }` for every field); `names` are nicer field names (`{ email: 'e-mail address' }`). |
|
|
1295
1295
|
| `req.params`, `req.query`, `req.body`, `req.header('x')`, `req.bearerToken()`, `req.ip()`, `req.user` | The rest. |
|
|
1296
1296
|
| `req.file('avatar').store('avatars')` | Saves an upload with a random name. |
|
|
1297
1297
|
| `response(data, 201)`, `response(data).header('X', 'y')`, `abort(403, 'message')` | Answers. |
|
|
1298
|
+
| `response.stream('/path/video.mp4', { type, download, headers })` | Sends a file with byte ranges. See [Video and audio files](#video-and-audio-files). |
|
|
1299
|
+
|
|
1300
|
+
### Video and audio files
|
|
1301
|
+
|
|
1302
|
+
To serve a video or audio file, return `response.stream(path)` instead of reading the file yourself:
|
|
1303
|
+
|
|
1304
|
+
```js
|
|
1305
|
+
const path = require('path')
|
|
1306
|
+
|
|
1307
|
+
Route.get('/videos/:name', (req) =>
|
|
1308
|
+
response.stream(path.join(__dirname, '../storage/videos', path.basename(req.params.name))))
|
|
1309
|
+
|
|
1310
|
+
Route.get('/podcast/:id/download', (req) =>
|
|
1311
|
+
response.stream('/var/media/episode.mp3', { download: true })) // saved as a file instead of played
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
- **Seeking works.** The server answers `Range` requests with `206 Partial Content`, so a player can jump to any minute without downloading the rest, and Safari, which refuses to play video without ranges, plays it. A range that does not fit the file gets `416`.
|
|
1315
|
+
- **Only the needed bytes are read**, and reading stops when the viewer leaves or seeks elsewhere.
|
|
1316
|
+
- **Caching is built in.** The answer carries `ETag` and `Last-Modified`, so an unchanged file is answered with `304`. `HEAD` requests get the headers only.
|
|
1317
|
+
- **A missing file is a plain `404`**, not a crash.
|
|
1318
|
+
- Options: `type` (the media type; guessed from the extension for `mp4`, `webm`, `mov`, `mp3`, `m4a`, `ogg`, `wav`, `flac`, `m3u8`, `mpd`, `vtt` …), `download: true` (adds `Content-Disposition: attachment`), and `headers` (extra headers).
|
|
1319
|
+
|
|
1320
|
+
> [!WARNING]
|
|
1321
|
+
> Never build the path straight from the request. Use `path.basename(...)` as above, or look the file up in your database, so nobody can ask for `../../.env`.
|
|
1322
|
+
|
|
1323
|
+
`kreft dev` and `kreft serve` also send the static media files of your app (`.mp4`, `.webm`, `.mp3`, HLS playlists …) with ranges, so a `<video src="/intro.mp4" controls>` seeks without any code.
|
|
1298
1324
|
|
|
1299
1325
|
### Form requests, resources and policies
|
|
1300
1326
|
|
|
@@ -1350,6 +1376,8 @@ class Post extends Model {
|
|
|
1350
1376
|
module.exports = Post
|
|
1351
1377
|
```
|
|
1352
1378
|
|
|
1379
|
+
**Always set `static fillable`.** Without it a model lets `create()` and `update()` set every column except `id`, so `Post.create(req.all())` would save `{ "is_admin": true }` from a visitor. The controllers made by `kreft make:controller --model` read only the `fillable` columns (`req.only(...Model.fillable)`) and stop with an error until you set it. Kreft prints a warning when a model without `fillable` is given a privilege-looking column (`is_admin`, `role`, `permissions` …).
|
|
1380
|
+
|
|
1353
1381
|
The table is the plural of the class name (`Post` is `posts`); set `static table = '…'` for another one.
|
|
1354
1382
|
|
|
1355
1383
|
### Querying
|
|
@@ -2263,10 +2291,80 @@ curl -H "Authorization: Bearer a-long-random-secret" -d '{"jsonrpc":"2.0","id":1
|
|
|
2263
2291
|
> [!WARNING]
|
|
2264
2292
|
> The MCP database tool can still read every other table. Use a database account with read-only access and no access to credentials, and keep the token out of version control.
|
|
2265
2293
|
|
|
2294
|
+
### Calling other MCP servers (client)
|
|
2295
|
+
|
|
2296
|
+
Your app can also call tools on a remote MCP server over Streamable HTTP:
|
|
2297
|
+
|
|
2298
|
+
```js
|
|
2299
|
+
const { Mcp } = require('kreftjs/server')
|
|
2300
|
+
|
|
2301
|
+
const quran = await Mcp.connect('https://mcp.quran.ai') // { headers: { Authorization: 'Bearer …' } } if needed
|
|
2302
|
+
const tools = await quran.tools()
|
|
2303
|
+
const verses = await quran.call('fetch_quran', { ayahs: '1:1-2' })
|
|
2304
|
+
await quran.close()
|
|
2305
|
+
```
|
|
2306
|
+
|
|
2307
|
+
`call` returns the structured result (or the text, parsed as JSON when possible) and throws if the tool reports an error. Only `http(s)` URLs are accepted, redirects are refused, and a response over `maxBytes` (10 MB) is cut off. Never pass a URL a visitor typed: your server would fetch addresses of their choosing, including your internal network (SSRF). Use a fixed or allow-listed URL.
|
|
2308
|
+
|
|
2309
|
+
## Running `kreft dev` outside a project
|
|
2310
|
+
|
|
2311
|
+
`kreft dev` and `kreft serve` start only in a Kreft project: a folder with `index.html`, a `frontend/` folder, `.kreft` pages, a backend or a `kreftjs.config.json`. In any other folder (an empty one, your home folder) they stop and say so, instead of serving whatever is there. `kreft dev --force` serves the folder anyway. `kreft run dev` is not a command: inside a project use `kreft dev`, or `npm run dev`.
|
|
2312
|
+
|
|
2313
|
+
## Kreft Shield
|
|
2314
|
+
|
|
2315
|
+
Floods and scanners from one address can slow a server to a crawl. Turn on the Shield with one line in `.env` (changes apply without a restart):
|
|
2316
|
+
|
|
2317
|
+
```
|
|
2318
|
+
SHIELD=true
|
|
2319
|
+
```
|
|
2320
|
+
|
|
2321
|
+
For every visitor address (the same `req.ip()` that `throttle` uses, so set `TRUSTED_PROXIES` behind a proxy):
|
|
2322
|
+
|
|
2323
|
+
| Protection | Default | Setting |
|
|
2324
|
+
|---|---|---|
|
|
2325
|
+
| Rate limit: over the limit answers `429` with `Retry-After` | 300 requests in 10 s | `SHIELD_MAX_REQUESTS`, `SHIELD_WINDOW_SEC` |
|
|
2326
|
+
| Probing counts more: `401`, `403`, `404` and `405` answers weigh 5 requests | 5 | `SHIELD_ERROR_WEIGHT` |
|
|
2327
|
+
| Concurrency: requests in flight at once from one address | 60 | `SHIELD_MAX_CONCURRENT` |
|
|
2328
|
+
| Ban: this many rate-limit hits within the strike window | 3 hits in 10 min | `SHIELD_STRIKES`, `SHIELD_STRIKE_WINDOW_MIN` |
|
|
2329
|
+
| Ban length, doubling at every repeat up to 24 h | 15 min | `SHIELD_BAN_MINUTES` |
|
|
2330
|
+
| Connections: open sockets from one address; extra ones are closed (stops slowloris floods that never finish a request) | 100 | `SHIELD_MAX_CONNECTIONS` |
|
|
2331
|
+
| Overload: when the event loop lags, limits shrink to a quarter | 300 ms lag | `SHIELD_MAX_LAG_MS` |
|
|
2332
|
+
|
|
2333
|
+
IPv6 visitors are counted by their `/64` prefix, because one customer owns billions of addresses in it and could otherwise rotate through them.
|
|
2334
|
+
|
|
2335
|
+
**Behind nginx, Caddy or a CDN, set `TRUSTED_PROXIES`.** Without it every visitor looks like the proxy: all users would share one limit and one ban, and a proxy on the same computer (`127.0.0.1`) is never limited at all. The Shield prints a warning when it sees `X-Forwarded-For` from an address that is not trusted.
|
|
2336
|
+
|
|
2337
|
+
Never limited: addresses in `SHIELD_ALLOW="1.2.3.4,5.6.7.8"` and this computer (`127.0.0.1`, `::1`), unless `SHIELD_LOCAL=true` (use it to try the Shield on your own machine). A banned address is answered with a bare `429` before any work is done.
|
|
2338
|
+
|
|
2339
|
+
```js
|
|
2340
|
+
const { Shield } = require('kreftjs/server')
|
|
2341
|
+
Shield.on('ban', (b) => Log.warning(`banned ${b.ip} for ${b.minutes} min: ${b.reason}`))
|
|
2342
|
+
Shield.bans() // who is banned now
|
|
2343
|
+
Shield.unban('1.2.3.4') // let an address back in
|
|
2344
|
+
Shield.ban('1.2.3.4', 60)
|
|
2345
|
+
Shield.status() // counters, limits, event-loop lag
|
|
2346
|
+
```
|
|
2347
|
+
|
|
2348
|
+
The MCP tool `shield_status` returns the same. Bans are kept in memory, so a restart clears them. The Shield protects the application; a flood that fills your network connection has to be stopped by a firewall or a CDN in front of the server.
|
|
2349
|
+
|
|
2266
2350
|
## Memory watchdog
|
|
2267
2351
|
|
|
2268
2352
|
Long-running servers can slowly collect memory. Start with `--gc` (or `AUTO_GC=1`) and Kreft samples the heap every 30 seconds (`AUTO_GC_INTERVAL_MS`). When it passes `AUTO_GC_THRESHOLD_MB`, Kreft clears its own caches and runs garbage collection. This is a safety net; find the leak if you see it often.
|
|
2269
2353
|
|
|
2354
|
+
The watchdog is a small memory manager:
|
|
2355
|
+
|
|
2356
|
+
- **Adaptive.** A cleanup also runs when the heap keeps growing (more than `AUTO_GC_GROWTH_MB_PER_MIN`, default 20) while already above 60% of the threshold, not only after it passes the limit.
|
|
2357
|
+
- **Least-used first.** The in-memory cache (`CACHE_STORE=memory`) keeps at most `CACHE_MEMORY_MAX` entries (default 5000) and, under pressure, drops expired and least recently used entries instead of everything. Register your own cache with `onMemoryPressure('name', (fraction) => dropped)`.
|
|
2358
|
+
- **Leak hint.** After each real GC (needs `--gc`) Kreft records the heap that remains. If it keeps rising by `AUTO_GC_LEAK_RISE_MB` (default 20) over several collections, it reports a suspected leak. `writeHeapSnapshot()` saves a `.heapsnapshot` for Chrome DevTools to show what holds the memory.
|
|
2359
|
+
- **Alerts.** `onMemoryAlert((alert) => …)` is called when memory is still high after `AUTO_GC_ALERT_AFTER` cleanups in a row (default 3), or a leak is suspected, at most once per 10 minutes. Without a listener the alert is printed as a warning.
|
|
2360
|
+
- **Report.** `getMemoryReport()` returns the samples, trend, GC and eviction counts, leak state and alerts as JSON; the MCP tool `memory_report` returns the same.
|
|
2361
|
+
|
|
2362
|
+
```js
|
|
2363
|
+
const { getMemoryReport, onMemoryAlert } = require('kreftjs/server')
|
|
2364
|
+
onMemoryAlert((a) => Log.warning(a.message))
|
|
2365
|
+
Route.get('/admin/memory', () => getMemoryReport()).middleware('auth')
|
|
2366
|
+
```
|
|
2367
|
+
|
|
2270
2368
|
# Security
|
|
2271
2369
|
|
|
2272
2370
|
## Security overview
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2
3
|
<plist version="1.0">
|
|
3
4
|
<dict>
|
|
4
5
|
<key>scope</key>
|
|
@@ -10,12 +11,14 @@
|
|
|
10
11
|
<dict>
|
|
11
12
|
<key>name</key>
|
|
12
13
|
<string>TM_COMMENT_START</string>
|
|
13
|
-
<value
|
|
14
|
+
<key>value</key>
|
|
15
|
+
<string><!-- </string>
|
|
14
16
|
</dict>
|
|
15
17
|
<dict>
|
|
16
18
|
<key>name</key>
|
|
17
19
|
<string>TM_COMMENT_END</string>
|
|
18
|
-
<value
|
|
20
|
+
<key>value</key>
|
|
21
|
+
<string> --></string>
|
|
19
22
|
</dict>
|
|
20
23
|
</array>
|
|
21
24
|
</dict>
|
|
@@ -3,8 +3,37 @@
|
|
|
3
3
|
name: Kreft
|
|
4
4
|
scope: text.html.kreft
|
|
5
5
|
version: 2
|
|
6
|
-
extends: Packages/HTML/HTML.sublime-syntax
|
|
7
6
|
file_extensions:
|
|
8
7
|
- kreft
|
|
9
8
|
- kreftjs
|
|
10
9
|
first_line_match: ^\s*<view\b
|
|
10
|
+
|
|
11
|
+
contexts:
|
|
12
|
+
main:
|
|
13
|
+
# <view>, <script> and <style> blocks are HTML (with JS and CSS inside)
|
|
14
|
+
- match: (?i)(?=<view\b)
|
|
15
|
+
embed: scope:text.html.basic
|
|
16
|
+
embed_scope: text.html.embedded.kreft
|
|
17
|
+
escape: (?i)(?<=</view>)
|
|
18
|
+
- match: (?i)(?=<script\b)
|
|
19
|
+
embed: scope:text.html.basic
|
|
20
|
+
embed_scope: text.html.embedded.kreft
|
|
21
|
+
escape: (?i)(?<=</script>)
|
|
22
|
+
- match: (?i)(?=<style\b)
|
|
23
|
+
embed: scope:text.html.basic
|
|
24
|
+
embed_scope: text.html.embedded.kreft
|
|
25
|
+
escape: (?i)(?<=</style>)
|
|
26
|
+
- match: <!--
|
|
27
|
+
scope: punctuation.definition.comment.begin.html
|
|
28
|
+
push: html-comment
|
|
29
|
+
# everything else at the top level is the component's JavaScript
|
|
30
|
+
- match: '^\s*(layout|prop|state|saved|derive|action|load|watch|when|on|event|use|share|receive)\b'
|
|
31
|
+
captures:
|
|
32
|
+
1: keyword.control.kreft storage.type.kreft
|
|
33
|
+
- include: scope:source.js
|
|
34
|
+
|
|
35
|
+
html-comment:
|
|
36
|
+
- meta_scope: comment.block.html
|
|
37
|
+
- match: -->
|
|
38
|
+
scope: punctuation.definition.comment.end.html
|
|
39
|
+
pop: true
|
|
@@ -4,5 +4,5 @@ layout ${1:app}
|
|
|
4
4
|
]]></content>
|
|
5
5
|
<tabTrigger>layout</tabTrigger>
|
|
6
6
|
<scope>text.html.kreft</scope>
|
|
7
|
-
<description>This page goes inside layouts
|
|
7
|
+
<description>This page goes inside layouts/<name>.kreft (layout none: no layout)</description>
|
|
8
8
|
</snippet>
|
package/kreftjs.d.ts
CHANGED
package/kreftjs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Kreft v0.0.
|
|
2
|
+
* Kreft v0.0.22-beta — reactive by nature
|
|
3
3
|
* The .kreft component language (state · derive · action · when · watch · on),
|
|
4
4
|
* { } views with k-if / k-each / k-model, components, slots, router, stores,
|
|
5
5
|
* share / receive, event bus and a template engine.
|
|
@@ -5601,7 +5601,7 @@
|
|
|
5601
5601
|
stores, storeLog, busLog,
|
|
5602
5602
|
};
|
|
5603
5603
|
|
|
5604
|
-
const VERSION = '0.0.
|
|
5604
|
+
const VERSION = '0.0.22-beta';
|
|
5605
5605
|
const Kreftjs = {
|
|
5606
5606
|
version: VERSION,
|
|
5607
5607
|
// PUBLIC_ values from .env (kreft dev and kreft build put them here); never secrets
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kreftjs",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22-beta",
|
|
4
4
|
"description": "A tiny reactive framework with its own component language (.kreft files): router with nested routes, stores, saved data, animations, an API client, hot reload, DevTools and a template engine.",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"main": "kreftjs.js",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"start": "node bin/kreftjs.js dev --api demo/db.json --db sqlite:demo/database/demo.db --migrations demo/database/migrations --server demo/server --backend demo --seed",
|
|
15
15
|
"build": "node bin/kreftjs.js build demo --out dist/kreftjs-com --base / --include docs",
|
|
16
16
|
"preview": "node bin/kreftjs.js dev dist --api demo/db.json --db sqlite:demo/database/demo.db --migrations demo/database/migrations --server demo/server --backend demo --seed",
|
|
17
|
-
"test": "node --test test/security.test.js test/prerender.test.js test/seo.test.js test/rebrand.test.js test/noauth.test.js test/winspawn.test.js test/htaccess.test.js test/cookie-secure.test.js test/password-rule.test.js test/headline.test.js test/menu.test.js test/electron-all.test.js test/build-minify.test.js test/stream.test.js test/syntax.test.js",
|
|
17
|
+
"test": "node --test test/security.test.js test/prerender.test.js test/seo.test.js test/rebrand.test.js test/noauth.test.js test/winspawn.test.js test/htaccess.test.js test/cookie-secure.test.js test/password-rule.test.js test/headline.test.js test/menu.test.js test/electron-all.test.js test/build-minify.test.js test/stream.test.js test/syntax.test.js test/memory.test.js test/shield.test.js test/mcp-client.test.js test/dbapi-secrets.test.js test/auth-csrf.test.js test/not-a-project.test.js",
|
|
18
18
|
"electron": "kreftjs electron",
|
|
19
19
|
"electron:dev": "kreftjs electron:dev",
|
|
20
20
|
"electron:build": "kreftjs electron:build"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"docs/",
|
|
32
32
|
"book/",
|
|
33
33
|
"assets/logo.png",
|
|
34
|
+
"!book/~$*",
|
|
34
35
|
"README.md"
|
|
35
36
|
],
|
|
36
37
|
"keywords": [
|