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.
@@ -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: new Set(rules.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.21-beta
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><![CDATA[<!-- ]]></value>
14
+ <key>value</key>
15
+ <string>&lt;!-- </string>
14
16
  </dict>
15
17
  <dict>
16
18
  <key>name</key>
17
19
  <string>TM_COMMENT_END</string>
18
- <value><![CDATA[ -->]]></value>
20
+ <key>value</key>
21
+ <string> --&gt;</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/<name>.kreft (layout none: no layout)</description>
7
+ <description>This page goes inside layouts/&lt;name&gt;.kreft (layout none: no layout)</description>
8
8
  </snippet>
@@ -6,5 +6,5 @@
6
6
  ]]></content>
7
7
  <tabTrigger>kreft-section</tabTrigger>
8
8
  <scope>text.html.kreft</scope>
9
- <description>Content for the layout's <kreft-yield name></description>
9
+ <description>Content for the layout's &lt;kreft-yield name&gt;</description>
10
10
  </snippet>
@@ -4,5 +4,5 @@
4
4
  ]]></content>
5
5
  <tabTrigger>kreft-yield</tabTrigger>
6
6
  <scope>text.html.kreft</scope>
7
- <description>In a layout: where a page's <kreft-section> goes</description>
7
+ <description>In a layout: where a page's &lt;kreft-section&gt; goes</description>
8
8
  </snippet>
package/kreftjs.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Type definitions for Kreft 0.0.21-beta
1
+ // Type definitions for Kreft 0.0.22-beta
2
2
  // Use from TypeScript or for editor hints in plain JavaScript (// @ts-check).
3
3
 
4
4
  export interface Signal<T> { value: T }
package/kreftjs.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Kreft v0.0.21-beta — reactive by nature
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.21-beta';
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.21-beta",
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": [