@christianirack/brainllm-runtime 0.3.1 → 0.5.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/server/server.mjs CHANGED
@@ -17,12 +17,14 @@
17
17
  // `build` is the build signature (§35.7): sent as X-Brain-Build on every HTML response and in /_health.
18
18
  // A page template (`page`) is the route's final HTML with `<!--brain:ssr-->` inside #brain-view and
19
19
  // `<!--brain:props-->` inside <script type="application/json" id="brain-props">.
20
- import { createServer as http } from 'node:http'
20
+ import { createServer as http, STATUS_CODES, request as httpRequest, Agent as HttpAgent } from 'node:http'
21
21
  import net from 'node:net'
22
+ import { installNativeSockets } from './native-sockets.mjs'
23
+ import { Readable } from 'node:stream'
22
24
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
23
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
25
+ import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
24
26
  import * as Module from 'node:module'
25
- import { basename, extname, join, normalize, resolve, sep } from 'node:path'
27
+ import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
26
28
  import { fileURLToPath, pathToFileURL } from 'node:url'
27
29
 
28
30
  const MIME = {
@@ -72,22 +74,358 @@ export class HttpError extends Error {
72
74
  Object.assign(this, extra)
73
75
  }
74
76
  }
75
- // `throw redirect('/login')` (or return it) from load/guards; only same-origin paths (open-redirect guard, §31)
77
+ // Plain deliberate 4xx signals are portable from server-only application modules; exception messages stay private.
78
+ function intendedStatus(error) {
79
+ if (!error || typeof error !== 'object') return null
80
+ const descriptor = Object.getOwnPropertyDescriptor(error, 'status')
81
+ const status = descriptor && descriptor.value
82
+ const plain = Object.getPrototypeOf(error) === Object.prototype || Object.getPrototypeOf(error) === null
83
+ return Number.isInteger(status) && STATUS_CODES[status] && (error instanceof HttpError && status >= 400 && status <= 599 || plain && status >= 400 && status < 500) ? status : null
84
+ }
85
+ const REDIRECT_CODES = new Set([301, 302, 303, 307, 308])
86
+ function safeLocation(value) {
87
+ if (typeof value !== 'string' || value.length > 8192 || !value.startsWith('/') || value.startsWith('//') || /[\\\x00-\x20\x7f]/.test(value) || /^\/%2f/i.test(value) || /%(?:0[0-9a-f]|1[0-9a-f]|5c|7f)/i.test(value) || /%20/i.test(value.split(/[?#]/,1)[0])) throw new HttpError(500, 'Invalid same-site redirect location')
88
+ return value
89
+ }
90
+ // `throw redirect('/login')` (or return it) from load/guards; same-site paths only.
76
91
  export const redirect = (url, status = 303) => {
77
- const u = String(url)
78
- if (!u.startsWith('/') || u.startsWith('//') || u.startsWith('/\\')) throw new HttpError(500, 'redirect to another origin is not allowed: ' + u)
79
- return { redirect: u, status }
92
+ if (!REDIRECT_CODES.has(status)) throw new HttpError(500, 'Invalid redirect status')
93
+ return { redirect: safeLocation(url), status }
94
+ }
95
+ function mediaQuality(header, kind) {
96
+ let specificity = -1, quality = 0
97
+ for (const part of String(header).slice(0, 8192).split(',').slice(0, 64)) {
98
+ const [raw, ...params] = part.trim().split(';'), name = raw.toLowerCase()
99
+ const rank = name === kind ? 2 : name === kind.split('/')[0] + '/*' ? 1 : name === '*/*' ? 0 : -1
100
+ if (rank < 0) continue
101
+ let q = 1
102
+ for (const param of params) {
103
+ const [key, value] = param.trim().split('=')
104
+ if (key.toLowerCase() === 'q') { q = Number(value); if (!value || !Number.isFinite(q) || q < 0 || q > 1) q = 0 }
105
+ }
106
+ if (rank > specificity || rank === specificity && q > quality) { specificity = rank; quality = q }
107
+ }
108
+ return quality
109
+ }
110
+ function wantsHtml(req) {
111
+ const path = req.$brainPath || String(req.url || '/').split('?')[0]
112
+ if (/^\/(?:api|_brain|_r)(?:\/|$)/.test(path)) return false
113
+ const accept = req.headers.accept
114
+ if (!accept) return true
115
+ const quality = mediaQuality(accept, 'text/html')
116
+ return quality > 0 && quality >= mediaQuality(accept, 'application/json')
117
+ }
118
+ function defaultError(status, rid, home = '/') {
119
+ const title = STATUS_CODES[status] || 'Request failed'
120
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${status} — ${esc(title)}</title></head><body><main><h1>${status} — ${esc(title)}</h1><p>The request could not be completed. Try again or contact support with this reference.</p><p>Reference: <code>${esc(rid)}</code></p><a href="${esc(home)}">Return home</a></main></body></html>`
121
+ }
122
+
123
+ // Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli).
124
+ export function acceptedEncodings(header = '') {
125
+ const values = new Map()
126
+ for (const part of String(header).split(',')) {
127
+ const [name, ...params] = part.trim().split(';')
128
+ let q = 1
129
+ for (const p of params) {
130
+ const [k,v] = p.trim().split('=')
131
+ if (k && k.trim().toLowerCase() === 'q') { q = v && v.trim() ? Number(v) : NaN; if (!Number.isFinite(q) || q < 0 || q > 1) q = 0 }
132
+ }
133
+ values.set(name.toLowerCase(),q)
134
+ }
135
+ return ['br','gzip'].map(name => [name,values.get(name) ?? values.get('*') ?? 0]).filter(([,q]) => q > 0).sort((a,b) => b[1] - a[1]).map(([name]) => name)
136
+ }
137
+
138
+ function googleSignInPage(page) {
139
+ let head = false, templates = 0
140
+ const tags = /<!--[\s\S]*?-->|<(script|style|title|noscript)\b(?:"[^"]*"|'[^']*'|[^'">])*?>[\s\S]*?<\/\1\s*>|<([^'">]|"[^"]*"|'[^']*')*>/gi
141
+ for (const match of page.matchAll(tags)) {
142
+ const tag = match[0]
143
+ if (tag.startsWith('<!--') || match[1]) continue
144
+ const name = /^<\/?([^\s/>]+)/.exec(tag)?.[1].toLowerCase(), closing = tag.startsWith('</')
145
+ if (name === 'head' && !templates) { if (closing) return false; head = true; continue }
146
+ if (name === 'body' && !templates) return false
147
+ if (name === 'template') { templates = Math.max(0, templates + (closing ? -1 : 1)); continue }
148
+ if (!closing && ['script', 'style', 'title', 'noscript'].includes(name)) return false
149
+ if (!head || templates || closing || name !== 'meta') continue
150
+ const attrs = new Map()
151
+ for (const item of tag.slice(5, -1).matchAll(/([^\s=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
152
+ const key = item[1].toLowerCase(); if (!attrs.has(key)) attrs.set(key, item[2] ?? item[3] ?? item[4] ?? '')
153
+ }
154
+ if (attrs.get('name')?.toLowerCase() === 'brain-google-signin' && attrs.get('content') === '1') return true
155
+ }
156
+ return false
157
+ }
158
+
159
+ // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
160
+ export function nativeAuthBridge(raw) {
161
+ if (!raw) return null
162
+ if (typeof raw !== 'string' || raw.length > 2048) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
163
+ let target
164
+ try { target = new URL(raw) } catch { throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin') }
165
+ if (target.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(target.hostname) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
166
+ if (target.hostname === 'localhost') target.hostname = '127.0.0.1'
167
+ const names = new Set(['__Host-brain', '__Host-brain-access', '__Host-brain-refresh'])
168
+ const agent = new HttpAgent({ keepAlive: true, maxSockets: 8, maxTotalSockets: 8, maxFreeSockets: 2, timeout: 30000 })
169
+ const owned = new Set()
170
+ let active = 0, stopped = false
171
+ const cookies = (req, includeRefresh) => {
172
+ const selected = new Map(), header = req.headers.cookie || ''
173
+ if (header.length > 16384) throw new HttpError(431, 'Authentication cookies exceed the request limit')
174
+ for (const part of header.split(';')) {
175
+ const at = part.indexOf('='); if (at < 0) continue
176
+ const name = part.slice(0, at).trim(), value = part.slice(at + 1).trim()
177
+ if (!names.has(name) || name === '__Host-brain-refresh' && !includeRefresh) continue
178
+ if (selected.has(name) || value.length > 8192 || !/^[a-zA-Z0-9._-]*$/.test(value)) throw new HttpError(400, 'Invalid authentication cookies')
179
+ selected.set(name, value)
180
+ }
181
+ return [...selected].map(([name, value]) => name + '=' + value).join('; ')
182
+ }
183
+ const safeUser = user => {
184
+ if (!user || typeof user.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id) || typeof user.email !== 'string' || user.email.length > 254 || typeof user.role !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(user.role) || ['public','owner'].includes(user.role) || typeof user.verified !== 'boolean') throw new HttpError(503,'Native authentication response is unavailable')
185
+ return Object.freeze({id:user.id,email:user.email,role:user.role,verified:user.verified})
186
+ }
187
+ const unavailable = () => new HttpError(503, 'Native authentication is temporarily unavailable')
188
+ const call = (incoming, response, path, method, rid, origin) => {
189
+ if (stopped || active >= 8) return Promise.reject(unavailable())
190
+ const cookie = cookies(incoming, method === 'POST')
191
+ active++
192
+ return new Promise((resolve, reject) => {
193
+ let outgoing, timer, finished = false
194
+ const finish = (error, value) => {
195
+ if (finished) return
196
+ finished = true; active--; clearTimeout(timer)
197
+ incoming.removeListener('aborted', cancel); response.removeListener('close', disconnected)
198
+ if (outgoing) owned.delete(outgoing)
199
+ error ? reject(unavailable()) : resolve(value)
200
+ }
201
+ const cancel = () => outgoing?.destroy(unavailable())
202
+ const disconnected = () => { if (!response.writableEnded) cancel() }
203
+ try {
204
+ outgoing = httpRequest(new URL(path, target), { method, agent, headers: { accept: 'application/json', ...(cookie ? { cookie } : {}), 'x-request-id': rid, ...(origin ? { origin } : {}), ...(method === 'POST' ? { 'content-length': '0' } : {}) } }, backend => {
205
+ let bytes = 0; const chunks = []
206
+ if (backend.headers['content-encoding'] || Number(backend.headers['content-length']) > 16384) { backend.destroy(); return finish(true) }
207
+ backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); finish(true) } else chunks.push(chunk) })
208
+ backend.on('error', () => finish(true))
209
+ backend.on('end', () => {
210
+ if (backend.statusCode === 401 && method === 'GET') return finish(false, { user: null })
211
+ if (backend.statusCode !== 200) return finish(true)
212
+ let payload
213
+ try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return finish(true) }
214
+ if (method === 'GET') {
215
+ try { return finish(false, {user:safeUser(payload?.user)}) } catch { return finish(true) }
216
+ }
217
+ if (payload?.ok !== true) return finish(true)
218
+ const cleared = []
219
+ for (const value of backend.headers['set-cookie'] || []) {
220
+ if (value.length > 8192 || /[\r\n]/.test(value)) return finish(true)
221
+ const name = value.split('=', 1)[0]
222
+ if (names.has(name) || name === '__Host-brain-google') cleared.push(value)
223
+ }
224
+ finish(false, { 'set-cookie': cleared, 'clear-site-data': '"storage"' })
225
+ })
226
+ })
227
+ owned.add(outgoing); incoming.once('aborted', cancel); response.once('close', disconnected)
228
+ timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
229
+ outgoing.once('error', () => finish(true)); outgoing.end()
230
+ } catch { outgoing?.destroy(); finish(true) }
231
+ })
232
+ }
233
+ let proxyBytes = 0
234
+ function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024) {
235
+ if (stopped || active >= 8) throw new HttpError(503, 'Backend request capacity is unavailable')
236
+ if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
237
+ const limit = url.pathname === '/_brain/uploads' && req.method === 'POST' ? 5 * 1024 * 1024 + 65536 : 65536
238
+ if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
239
+ active++
240
+ return new Promise((resolve, reject) => {
241
+ let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
242
+ const chunks = []
243
+ const headers = { 'x-request-id':rid }
244
+ for (const key of ['origin','authorization','content-type','content-length','x-brain-rpc','accept','x-brain-webhook-id','x-brain-webhook-timestamp','x-brain-webhook-signature']) {
245
+ const value = req.headers[key]
246
+ if (typeof value === 'string' && value.length <= 8192) headers[key] = value
247
+ }
248
+ const selected = []
249
+ for (const part of String(req.headers.cookie || '').split(';')) {
250
+ const name = part.trim().split('=')[0]
251
+ if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
252
+ }
253
+ if (selected.length) headers.cookie = selected.join('; ')
254
+ const finish = (error, backend) => {
255
+ if (done) return
256
+ done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
257
+ req.removeListener('aborted',cancel); req.removeListener('data',data); req.removeListener('end',end)
258
+ res.removeListener('close',cancel)
259
+ if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
260
+ const safe = { 'cache-control':'no-store' }
261
+ for (const key of ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data']) {
262
+ const value = backend.headers[key]
263
+ if (typeof value === 'string' && value.length <= 8192) safe[key] = value
264
+ }
265
+ const cookies = (backend.headers['set-cookie'] || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192)
266
+ if (cookies.length) safe['set-cookie'] = cookies.slice(0,8)
267
+ try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
268
+ }
269
+ const cancel = () => { outgoing?.destroy(); finish(true) }
270
+ const data = chunk => {
271
+ requestBytes += chunk.length
272
+ if (requestBytes > limit) { failure = new HttpError(413,'Request body is too large'); outgoing.destroy(); finish(true); return }
273
+ if (!outgoing.write(chunk)) { req.pause(); outgoing.once('drain',()=>req.resume()) }
274
+ }
275
+ const end = () => outgoing.end()
276
+ try {
277
+ outgoing = httpRequest(new URL(url.pathname + url.search,target),{agent,method:req.method,headers},backend => {
278
+ backend.on('data',chunk => {
279
+ bytes += chunk.length; proxyBytes += chunk.length
280
+ if (bytes > responseLimit || proxyBytes > 64 * 1024 * 1024) { failure = new HttpError(502,'Backend response exceeds its byte budget'); outgoing.destroy(); finish(true); return }
281
+ chunks.push(chunk)
282
+ })
283
+ backend.once('end',()=>finish(false,backend)); backend.once('error',()=>finish(true)); backend.once('aborted',()=>finish(true))
284
+ })
285
+ owned.add(outgoing)
286
+ timer = setTimeout(()=>{failure = new HttpError(504,'Backend request timed out');outgoing.destroy();finish(true)},url.pathname === '/_brain/uploads' || url.pathname.startsWith('/_brain/uploads/') ? 35000 : 10000)
287
+ outgoing.once('error',()=>finish(true)); req.once('aborted',cancel); res.once('close',cancel)
288
+ req.on('data',data); req.once('end',end)
289
+ if (req.readableEnded) end()
290
+ } catch { outgoing?.destroy(); finish(true) }
291
+ })
292
+ }
293
+ const resource = (incoming, response, rid, name) => {
294
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
295
+ const idPath = id => {
296
+ if (typeof id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id)) throw new HttpError(400, 'Invalid native record identifier')
297
+ return '/api/' + name + '/' + id
298
+ }
299
+ const request = async (method, path, body) => {
300
+ if (response.writableEnded || response.destroyed) throw new HttpError(409, 'The server request has ended')
301
+ if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
302
+ let bytes = Buffer.alloc(0)
303
+ if (body !== undefined) {
304
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'Native resource values must be an object')
305
+ try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
306
+ if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
307
+ }
308
+ const req = Readable.from(bytes.length ? [bytes] : [])
309
+ req.method = method
310
+ req.headers = { cookie: cookies(incoming, false), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
311
+ if (method !== 'GET') {
312
+ let origin
313
+ try { origin = new URL(incoming.headers.origin); if (origin.origin !== incoming.headers.origin || origin.host !== (incoming.headers['x-forwarded-host'] || incoming.headers.host)) throw Error() } catch { throw new HttpError(403, 'Native writes require a same-origin server request') }
314
+ req.headers.origin = origin.origin
315
+ }
316
+ let value
317
+ try {
318
+ await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
319
+ if (status < 200 || status >= 300) throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed')
320
+ if (status === 204) { value = null; return }
321
+ if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
322
+ try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
323
+ }, 2 * 1024 * 1024)
324
+ } finally { req.destroy() }
325
+ if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
326
+ return value
327
+ }
328
+ return Object.freeze({
329
+ list(options = {}) {
330
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !['search','limit','offset'].includes(key))) throw new HttpError(400, 'Invalid native list options')
331
+ const {search = '', limit = 20, offset = 0} = options
332
+ if (typeof search !== 'string' || Buffer.byteLength(search) > 256 || !Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(offset) || offset < 0 || offset > 10000) throw new HttpError(400, 'Invalid native list pagination or search')
333
+ return request('GET', '/api/' + name + '?' + new URLSearchParams({search,limit:String(limit),offset:String(offset)}))
334
+ },
335
+ get: id => request('GET', idPath(id)),
336
+ create: values => request('POST', '/api/' + name, values),
337
+ update: (id, values) => request('PUT', idPath(id), values),
338
+ remove: id => request('DELETE', idPath(id)),
339
+ })
340
+ }
341
+ async function login(incoming,response,rid,credentials) {
342
+ if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
343
+ if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
344
+ if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials) || Object.keys(credentials).some(key=>!['email','password'].includes(key)) || typeof credentials.email !== 'string' || typeof credentials.password !== 'string' || Buffer.byteLength(credentials.email)>254 || Buffer.byteLength(credentials.password)>1024) throw new HttpError(422,'Enter a valid email and password')
345
+ let origin
346
+ try { origin=new URL(incoming.headers.origin);if(origin.origin!==incoming.headers.origin || origin.host!==(incoming.headers['x-forwarded-host']||incoming.headers.host))throw Error() }catch{throw new HttpError(403,'Sign in through a same-origin POST server function')}
347
+ incoming.$nativeLoginStarted=true
348
+ const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
349
+ const request=Readable.from([bytes]);request.method='POST';request.headers={origin:origin.origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
350
+ let user
351
+ try {
352
+ await proxy(request,response,new URL('/_brain/auth/login',target),rid,(_res,status,data,headers)=>{
353
+ if(status!==200)throw new HttpError(status===400||status===401||status===422?401:status===429?429:503,status===429?'Too many attempts. Wait before trying again.':status<500?'Wrong email or password':'Sign-in is temporarily unavailable')
354
+ let payload
355
+ try {payload=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
356
+ user=safeUser(payload?.user)
357
+ const selected=headers['set-cookie']||[]
358
+ if(new Set(selected.map(value=>value.split('=',1)[0])).size!==selected.length)throw unavailable()
359
+ if(!selected.length||selected.length>3||selected.some(value=>!/^(?:__Host-brain|__Host-brain-access|__Host-brain-refresh)=/.test(value)||!/(?:^|;)\s*HttpOnly(?:;|$)/i.test(value)||!/(?:^|;)\s*Secure(?:;|$)/i.test(value)||!/(?:^|;)\s*Path=\/(?:;|$)/i.test(value)||!/(?:^|;)\s*SameSite=Lax(?:;|$)/i.test(value)))throw unavailable()
360
+ if(response.writableEnded||response.destroyed)throw unavailable()
361
+ incoming.$nativeLoginHeaders={'set-cookie':selected,'cache-control':'no-store'}
362
+ },16384)
363
+ } finally {request.destroy();bytes.fill(0)}
364
+ return {user}
365
+ }
366
+ return {
367
+ proxy,
368
+ resource,
369
+ login,
370
+ async prepare(req, res, rid) {
371
+ if (!cookies(req, false)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
372
+ if (!req.$nativeAuth) req.$nativeAuth = call(req, res, '/_brain/auth/me', 'GET', rid).then(result => { req.$nativeUser = result.user })
373
+ await req.$nativeAuth
374
+ },
375
+ session(req, res, rid) {
376
+ return {
377
+ user: req.$nativeUser,
378
+ set() { throw new HttpError(409, 'Use the native authentication API to establish a session') },
379
+ clear() {
380
+ if (req.$nativeLoginStarted) throw new HttpError(409,'Use a separate server request to sign out after signing in')
381
+ if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'Sign out through a same-origin POST request')
382
+ let origin
383
+ try { origin = new URL(req.headers.origin); if (origin.origin !== req.headers.origin || origin.host !== (req.headers['x-forwarded-host'] || req.headers.host)) throw Error() } catch { throw new HttpError(403, 'Sign out through a same-origin POST request') }
384
+ if (!req.$nativeLogout) {
385
+ req.$nativeLogout = call(req, res, '/_brain/auth/logout', 'POST', rid, origin.origin).then(headers => { req.$nativeLogoutDone = true; req.$nativeLogoutHeaders = headers }, error => { req.$nativeLogoutDone = true; req.$nativeLogoutError = true; throw error })
386
+ req.$nativeLogout.catch(() => {})
387
+ }
388
+ return req.$nativeLogout
389
+ },
390
+ headers: () => req.$nativeLoginHeaders || {},
391
+ }
392
+ },
393
+ _socketCookie: req => cookies(req, false),
394
+ close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy() },
395
+ }
80
396
  }
81
397
 
82
398
  export function createServer(o = {}) {
83
399
  const dist = resolve(o.dist || process.cwd())
84
400
  const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
401
+ const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL)
402
+ if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
85
403
  const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
86
404
  const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
87
405
  const MAX_BODY = 64 * 1024
88
406
  const secret = o.secret ?? env.BRAIN_SECRET ?? ''
89
- const log = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
407
+ const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
408
+ const log = (level, msg, extra = {}) => {
409
+ const safe = { ...extra }
410
+ // Error messages may contain credentials or request data. Retain only bounded V8 source frames.
411
+ if (typeof safe.stack === 'string') {
412
+ const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
413
+ safe.errorType = kind
414
+ safe.code = kind === 'Error' ? 'NODE_INTERNAL_ERROR' : 'NODE_' + kind.replace(/Error$/, '').toUpperCase() + '_ERROR'
415
+ const truncated = safe.stack.length > 32768 || safe.stack.split('\n').length > 41
416
+ safe.traceTruncated = truncated
417
+ safe.stack = safe.stack.slice(0, 32768).split('\n').filter(line => /^\s+at (?:async )?(?:[A-Za-z0-9_.$<>]+(?: \[as [A-Za-z0-9_$]+\])? \()?(?:file:\/\/\/|\/|node:)[^\s()?#]*:\d+:\d+\)?$/.test(line) && line.length <= 1024).slice(0, 40).join('\n')
418
+ safe.private = true; safe.valuesCaptured = false
419
+ safe.fix = kind === 'TypeError' ? 'Check nullable values, argument types and the data contract at these source frames' : kind === 'ReferenceError' ? 'Check the missing binding or import at these source frames' : kind === 'RangeError' ? 'Check recursive calls, array sizes and configured resource budgets at these source frames' : kind === 'SyntaxError' ? 'Check module syntax or JSON input parsing at these source frames without logging input values' : 'Inspect these source frames and the request reference; reproduce with synthetic inputs'
420
+ }
421
+ if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
422
+ sink(level, msg, safe)
423
+ }
90
424
  const mf = readManifest(dist)
425
+ const hasManifestSockets = Object.hasOwn(mf,'nativeSockets')
426
+ if (hasManifestSockets && typeof mf.nativeSockets !== 'boolean') throw Error('Manifest nativeSockets must be an explicit boolean')
427
+ const nativeSockets = o.nativeSockets === undefined ? hasManifestSockets && mf.nativeSockets : o.nativeSockets
428
+ if (nativeSockets && !native) throw Error('Native sockets require a configured native backend')
91
429
  // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
92
430
  const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
93
431
  // the real path: Node imports file URLs through realpath, and the /_b/ hook must name the same modules (a symlinked
@@ -112,9 +450,41 @@ export function createServer(o = {}) {
112
450
  hook(distURL, BASE)
113
451
 
114
452
  // ---------- helpers ----------
115
- const send = (res, status, body, headers = {}) => {
453
+ const send = (res, status, body, headers = {}, configuredExternal = false) => {
454
+ if (native && res.req.$nativeLogout && !res.req.$nativeLogoutDone) {
455
+ res.req.$nativeLogout.then(() => send(res, status, body, headers, configuredExternal), () => send(res, 503, '', {}, false))
456
+ return
457
+ }
458
+ if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false }
459
+ if (native && res.req.$nativeLogoutHeaders) {
460
+ const relay = res.req.$nativeLogoutHeaders, own = headers['set-cookie']
461
+ headers = { ...headers, ...relay, 'set-cookie': [...(own ? Array.isArray(own) ? own : [own] : []), ...relay['set-cookie']] }
462
+ }
463
+ if (!Number.isInteger(status) || status < 200 || status > 599 || !STATUS_CODES[status]) throw new HttpError(500, 'Invalid final response status')
464
+ if (headers.location != null) {
465
+ if (configuredExternal && /^https?:\/\//.test(headers.location)) {
466
+ const target = new URL(headers.location)
467
+ if (target.username || target.password || /[\\\x00-\x20\x7f]/.test(headers.location)) throw new HttpError(500, 'Invalid configured redirect location')
468
+ } else safeLocation(headers.location)
469
+ }
470
+ if (status >= 400) {
471
+ headers = { ...headers, 'cache-control': 'private, no-store', vary: [headers.vary, 'Accept'].filter(Boolean).join(', ') }
472
+ const kind = headers['content-type'] || ''
473
+ if (!kind || kind.startsWith('text/plain')) {
474
+ const rid = res.getHeader('x-request-id') || ''
475
+ if (wantsHtml(res.req)) {
476
+ body = defaultError(status, rid, BASE)
477
+ headers['content-type'] = MIME['.html']
478
+ headers['content-security-policy'] = mf.csp || CSP0
479
+ } else {
480
+ body = json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' })
481
+ headers['content-type'] = MIME['.json']
482
+ }
483
+ delete headers['content-length']; delete headers['content-encoding']; delete headers.etag
484
+ }
485
+ }
116
486
  res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
117
- res.end(body)
487
+ res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
118
488
  }
119
489
  // HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
120
490
  // taken before the per-response nonce is injected, so it is stable
@@ -126,20 +496,21 @@ export function createServer(o = {}) {
126
496
  }
127
497
  const nonce = randomBytes(16).toString('base64')
128
498
  const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
129
- send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
499
+ send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
130
500
  }
131
- const notFound = (req, res) => {
132
- const f = mf.notFound && file(mf.notFound)
133
- if (f) return html(res, 404, readFileSync(f, 'utf8'))
134
- html(res, 404, '<!doctype html><title>Not found</title><main><h1>Not found</h1></main>')
501
+ const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
502
+ const failPage = (res, status, rid) => {
503
+ if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
504
+ const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || `${status}.html`
505
+ const path = typeof custom === 'string' && file(custom)
506
+ return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
135
507
  }
136
- const failPage = (res, status, rid) =>
137
- html(res, status, `<!doctype html><title>Error</title><main><h1>${status === 504 ? 'The page took too long' : 'Something went wrong'}</h1><p>Reference: ${esc(rid)}</p></main>`)
138
508
  // a file inside dist/ (never outside: no `..`, no absolute paths)
139
509
  function file(rel) {
140
510
  const p = normalize(join(dist, rel))
141
511
  if (p !== dist && !p.startsWith(dist + sep)) return null
142
512
  try {
513
+ for (let cursor = p; cursor !== dist; cursor = dirname(cursor)) if (lstatSync(cursor).isSymbolicLink()) return null
143
514
  return statSync(p).isFile() ? p : null
144
515
  } catch {
145
516
  return null
@@ -154,9 +525,10 @@ export function createServer(o = {}) {
154
525
 
155
526
  const context = (req, res, url, extra) => {
156
527
  const cookies = parseCookies(req.headers.cookie)
157
- const { user, session: ses } = sessionFrom(cookies, secret)
528
+ const { user, session: ses } = native ? { user: req.$nativeUser ?? null, session: native.session(req, res, res.getHeader('x-request-id')) } : sessionFrom(cookies, secret)
158
529
  return {
159
530
  url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
531
+ ...(native ? { backend: Object.freeze({resource: name => native.resource(req, res, res.getHeader('x-request-id'), name),login:credentials=>native.login(req,res,res.getHeader('x-request-id'),credentials)}) } : {}),
160
532
  // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
161
533
  prefetch: req.headers['x-brain-prefetch'] === '1',
162
534
  req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
@@ -221,10 +593,11 @@ export function createServer(o = {}) {
221
593
  } catch {}
222
594
  }
223
595
  if (!nocache && route.cache > 0 && !forms && !fixed && status === 200 && anonymous(req)) return cached(req, res, route, params, url, rid)
596
+ if (native) await native.prepare(req, res, rid)
224
597
  const ctx = context(req, res, url, { params })
225
598
  // a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
226
599
  // user's page from the HTTP cache or bfcache. Anonymous pages keep `no-cache` + ETag.
227
- const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined
600
+ const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
228
601
  const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
229
602
  let props = fixed || {}, extra = {}
230
603
  if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
@@ -238,7 +611,7 @@ export function createServer(o = {}) {
238
611
  log('error', 'guard failed', { rid, path: url.pathname, stack: String((e && e.stack) || e) })
239
612
  return errorPage(req, res, route, params, url, rid)
240
613
  }
241
- if (r.redirect) return send(res, r.status, '', { location: withBase(r.redirect), 'cache-control': 'no-store', ...ctx.session.headers() })
614
+ if (r.redirect) return send(res, r.status, '', { location: withBase(redirect(r.redirect, r.status ?? 303).redirect), 'cache-control': 'no-store', ...ctx.session.headers() })
242
615
  if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
243
616
  extra = r.headers
244
617
  }
@@ -249,13 +622,13 @@ export function createServer(o = {}) {
249
622
  if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
250
623
  }
251
624
  } catch (e) {
252
- if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(e.redirect), ...ctx.session.headers() })
625
+ if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
253
626
  if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
254
- const status = e instanceof HttpError && e.status === 504 ? 504 : 500
627
+ const status = intendedStatus(e) || 500
255
628
  log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
256
629
  return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
257
630
  }
258
- if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(props.redirect), ...ctx.session.headers() })
631
+ if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(redirect(props.redirect, props.status ?? 303).redirect), ...ctx.session.headers() })
259
632
  if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
260
633
  let body, seo, failed
261
634
  try {
@@ -333,7 +706,7 @@ export function createServer(o = {}) {
333
706
  props = (await loadP) ?? {}
334
707
  if (props.redirect || props.notFound === true) throw props
335
708
  } catch (e) {
336
- if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(e.redirect))})</script>`)
709
+ if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(redirect(e.redirect, e.status ?? 303).redirect))})</script>`)
337
710
  // a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
338
711
  if (e && e.notFound === true) return end(`<meta name="robots" content="noindex"><p role="alert" class="brain-fallback">Page not found.</p>`, {}, bottom.replace(/<script type="module">(?:(?!<\/script>)[\s\S])*?brain-view[\s\S]*?<\/script>/g, ''))
339
712
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, stack: String((e && e.stack) || e) }) // headers already out
@@ -362,14 +735,15 @@ export function createServer(o = {}) {
362
735
  }
363
736
  // deny → views/403.brain (manifest `forbidden`, props.reason) or the static 403.html, always status 403
364
737
  function forbidden(req, res, url, rid, reason) {
738
+ if (!wantsHtml(req)) return failPage(res, 403, rid)
365
739
  if (mf.forbidden) return ssr(req, res, mf.forbidden, {}, url, rid, null, 403, { reason })
366
740
  const f = file('403.html')
367
741
  if (f) return html(res, 403, readFileSync(f, 'utf8'))
368
- send(res, 403, 'Forbidden', { 'content-type': MIME['.txt'] })
742
+ return failPage(res, 403, rid)
369
743
  }
370
744
  // a failing load or guard → the folder's _error.brain (route `error`, props { message, rid }), else the generic page
371
745
  function errorPage(req, res, route, params, url, rid) {
372
- if (route.error) return ssr(req, res, route.error, params, url, rid, null, 500, { message: 'Something went wrong.', rid })
746
+ if (wantsHtml(req) && route.error) return ssr(req, res, route.error, params, url, rid, null, 500, { message: 'Something went wrong.', rid })
373
747
  return failPage(res, 500, rid)
374
748
  }
375
749
  async function render(route, props, forms, at = null) {
@@ -437,6 +811,7 @@ export function createServer(o = {}) {
437
811
  } catch {
438
812
  return fail(413, 'Body larger than 64 KB')
439
813
  }
814
+ if (native) await native.prepare(req, res, rid)
440
815
  const ctx = context(req, res, url, { params: {} })
441
816
  let f
442
817
  try {
@@ -478,6 +853,7 @@ export function createServer(o = {}) {
478
853
  }
479
854
  const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
480
855
  if (!modPath) return plain(400, 'Unknown form')
856
+ if (native) await native.prepare(req, res, rid)
481
857
  const ctx = context(req, res, url, { params })
482
858
  let module, rules
483
859
  try {
@@ -573,15 +949,27 @@ export function createServer(o = {}) {
573
949
  // ---------- static ----------
574
950
  // §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
575
951
  // `/` → index.md) when the build published one (`seo.markdown`, default true; never for noindex pages).
952
+ function publicStaticPath(pathname) {
953
+ let path
954
+ try { path = decodeURIComponent(pathname) } catch { return null }
955
+ if (/[\\\x00]/.test(path)) return null
956
+ const clean = normalize(path).replaceAll('\\', '/')
957
+ const rel = clean.replace(/^\/+/, '')
958
+ if (rel.startsWith('_server') || rel.startsWith('_brain') || rel.startsWith('server.mjs') || rel === '_headers' || rel === '_redirects' || clean.split('/').some(part => part.startsWith('.') && part !== '.well-known')) return null
959
+ return path
960
+ }
576
961
  function serveTwin(req, res, url) {
577
962
  if (!prefersMarkdown(req.headers.accept)) return false
578
- const md = file(mdPath(decodeURIComponent(url.pathname)))
963
+ const path = publicStaticPath(url.pathname)
964
+ if (path === null) return false
965
+ const md = file(mdPath(path))
579
966
  if (!md) return false
580
967
  send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
581
968
  return true
582
969
  }
583
970
  function serveStatic(req, res, url) {
584
- let p = decodeURIComponent(url.pathname)
971
+ const p = publicStaticPath(url.pathname)
972
+ if (p === null) return false
585
973
  const candidates = p.endsWith('/') ? [p + 'index.html'] : [p, p + '.html', p + '/index.html']
586
974
  for (const c of candidates) {
587
975
  const f = file(c)
@@ -593,7 +981,7 @@ export function createServer(o = {}) {
593
981
  const md = file(mdPath(p))
594
982
  const page = readFileSync(f, 'utf8')
595
983
  // static HTML changes only with a deploy: its ETag is the build signature (content hash without a manifest)
596
- return html(res, 200, page, md ? { vary: 'accept' } : {}, '"' + (mf.build || tag(page)) + '"'), true
984
+ return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
597
985
  }
598
986
  const headers = {
599
987
  'content-type': type,
@@ -601,10 +989,11 @@ export function createServer(o = {}) {
601
989
  'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
602
990
  vary: 'accept-encoding',
603
991
  }
604
- const ae = String(req.headers['accept-encoding'] || '')
605
- for (const [enc, suffix] of [['br', '.br'], ['gzip', '.gz']]) {
606
- if (ae.includes(enc) && existsSync(f + suffix)) {
607
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f + suffix), { ...headers, 'content-encoding': enc })
992
+ for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
993
+ const suffix = enc === 'br' ? '.br' : '.gz'
994
+ const encoded = file(c + suffix)
995
+ if (encoded) {
996
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(encoded), { ...headers, 'content-encoding': enc })
608
997
  return true
609
998
  }
610
999
  }
@@ -621,7 +1010,7 @@ export function createServer(o = {}) {
621
1010
  const t0 = performance.now()
622
1011
  res.on('finish', () => {
623
1012
  const ms = performance.now() - t0
624
- if (ms > 1000) log('warn', 'slow request', { rid, path: req.url, status: res.statusCode, ms: Math.round(ms) })
1013
+ if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
625
1014
  })
626
1015
  try {
627
1016
  const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
@@ -633,12 +1022,14 @@ export function createServer(o = {}) {
633
1022
  if (!full.pathname.startsWith(BASE)) return notFound(req, res)
634
1023
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
635
1024
  }
1025
+ req.$brainPath = url.pathname
1026
+ if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
636
1027
  // redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
637
1028
  // same-site targets get the base, the query is kept when the target has none
638
1029
  const to = redirectOf(url.pathname)
639
1030
  if (to && (req.method === 'GET' || req.method === 'HEAD')) {
640
1031
  const loc = /^https?:\/\//.test(to[0]) ? to[0] : withBase(to[0]) + (to[0].includes('?') ? '' : url.search)
641
- return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' })
1032
+ return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' }, true)
642
1033
  }
643
1034
  const r = /^\/_r\/([\w-]+)\/([\w$]+)$/.exec(url.pathname)
644
1035
  if (r) return await rpc(req, res, r[1], r[2], url, rid)
@@ -660,10 +1051,12 @@ export function createServer(o = {}) {
660
1051
  notFound(req, res)
661
1052
  } catch (e) {
662
1053
  log('error', 'unhandled', { rid, path: req.url, stack: String((e && e.stack) || e) })
663
- if (!res.headersSent) failPage(res, 500, rid)
1054
+ if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
664
1055
  else res.end()
665
1056
  }
666
1057
  })
1058
+ if (native) server.once('close', () => native.close())
1059
+ if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,log})
667
1060
  server.headersTimeout = 15000
668
1061
  server.requestTimeout = 30000
669
1062
  return server
@@ -719,14 +1112,14 @@ export function sessionFrom(cookies, secret) {
719
1112
  // `_middleware.jsbrain`: async (ctx, next) => …; may set ctx.headers (response headers) or stop with redirect/deny.
720
1113
  // The same shapes come out of brain-router's redirect()/deny() in the browser.
721
1114
  export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
722
- const samePath = u => typeof u === 'string' && u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')
1115
+ const samePath = u => { try { safeLocation(u); return true } catch { return false } }
723
1116
  function verdict(v) {
724
1117
  if (v === false) return { deny: 'Forbidden' }
725
1118
  if (typeof v === 'string') v = { redirect: v }
726
1119
  if (!v || typeof v !== 'object') return null
727
1120
  if (v.redirect !== undefined) {
728
1121
  if (!samePath(v.redirect)) throw new HttpError(500, 'redirect to another origin is not allowed: ' + v.redirect) // §31 open redirect
729
- return { redirect: v.redirect, status: v.status || 303 }
1122
+ return redirect(v.redirect, v.status ?? 303)
730
1123
  }
731
1124
  return v.deny !== undefined ? { deny: String(v.deny) } : null
732
1125
  }
@@ -811,7 +1204,9 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
811
1204
  } catch (e) {
812
1205
  if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
813
1206
  if (e instanceof HttpError && e.status === 504) return { status: 504, body: { error: 'Timeout' }, cause: e }
814
- if (e instanceof HttpError && e.status >= 400 && e.status < 500) return { status: e.status, body: { error: e.message } }
1207
+ const deliberate = intendedStatus(e)
1208
+ if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError ? e.message : STATUS_CODES[deliberate] } }
1209
+ if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
815
1210
  return { status: 500, body: { error: 'Internal error' }, cause: e }
816
1211
  }
817
1212
  }
@@ -835,10 +1230,13 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
835
1230
  } catch (e) {
836
1231
  if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
837
1232
  if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
1233
+ const deliberate = intendedStatus(e)
1234
+ if (deliberate && deliberate < 500) return {status:deliberate,body:{error:STATUS_CODES[deliberate]}}
1235
+ if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
838
1236
  return again({}, 'Something went wrong. Try again.', e)
839
1237
  }
840
1238
  if (r && r.errors) return again(r.errors, '')
841
- const to = r && typeof r.redirect === 'string' && r.redirect.startsWith('/') && !r.redirect.startsWith('//') ? r.redirect : ctx.url.pathname + ctx.url.search
1239
+ const to = r && r.redirect != null ? safeLocation(r.redirect) : ctx.url.pathname + ctx.url.search
842
1240
  return { status: 303, location: to }
843
1241
  }
844
1242
 
@@ -852,7 +1250,15 @@ function coerce(F, schema, q, base = '', values = {}, errors = {}) {
852
1250
  coerce(F, r.s, q, path, values[k], errors)
853
1251
  continue
854
1252
  }
855
- const one = v => (r.t === 'num' ? (v === '' || v == null ? null : Number(v)) : r.t === 'bool' ? v === 'on' || v === 'true' || v === '1' : v == null ? '' : v)
1253
+ const one = v => {
1254
+ if (r.t === 'num') {
1255
+ if (v == null || v.trim() === '') return null
1256
+ // Validate the original decimal token before conversion; Number('0x10') hides invalid form input.
1257
+ return F.check({ ...r, list: false, req: false, min: null, max: null }, v) ? v : Number(v)
1258
+ }
1259
+ if (r.t === 'bool') return v == null || v === '' || v === 'false' || v === '0' ? false : v === 'on' || v === 'true' || v === '1' ? true : v
1260
+ return v == null ? '' : v
1261
+ }
856
1262
  const v = r.list ? q.getAll(path).map(one) : r.t === 'bool' ? one(q.get(path)) : one(q.get(path) ?? '')
857
1263
  values[k] = v
858
1264
  const msg = F.check(r, r.t === 'num' && !r.list && v === null ? '' : r.t === 'num' && !r.list && Number.isNaN(v) ? q.get(path) : v)
@@ -1015,6 +1421,7 @@ function real(p) {
1015
1421
  }
1016
1422
  }
1017
1423
  if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
1424
+ process.env.NODE_ENV = 'production'
1018
1425
  const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
1019
1426
  const fd = ENV.BRAIN_LISTEN_FD
1020
1427
  const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
@@ -1027,6 +1434,7 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
1027
1434
  const drain = () => {
1028
1435
  if (s.draining) return
1029
1436
  s.draining = true
1437
+ s.$brainSocketStop?.()
1030
1438
  const ms = +(ENV.BRAIN_DRAIN_MS || 10000)
1031
1439
  const force = setTimeout(() => {
1032
1440
  s.closeAllConnections()