@christianirack/brainllm-runtime 0.3.1 → 0.4.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,13 @@
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 { Readable } from 'node:stream'
22
23
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
23
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
24
+ import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
24
25
  import * as Module from 'node:module'
25
- import { basename, extname, join, normalize, resolve, sep } from 'node:path'
26
+ import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
26
27
  import { fileURLToPath, pathToFileURL } from 'node:url'
27
28
 
28
29
  const MIME = {
@@ -72,21 +73,351 @@ export class HttpError extends Error {
72
73
  Object.assign(this, extra)
73
74
  }
74
75
  }
75
- // `throw redirect('/login')` (or return it) from load/guards; only same-origin paths (open-redirect guard, §31)
76
+ // Plain deliberate 4xx signals are portable from server-only application modules; exception messages stay private.
77
+ function intendedStatus(error) {
78
+ if (!error || typeof error !== 'object') return null
79
+ const descriptor = Object.getOwnPropertyDescriptor(error, 'status')
80
+ const status = descriptor && descriptor.value
81
+ const plain = Object.getPrototypeOf(error) === Object.prototype || Object.getPrototypeOf(error) === null
82
+ return Number.isInteger(status) && STATUS_CODES[status] && (error instanceof HttpError && status >= 400 && status <= 599 || plain && status >= 400 && status < 500) ? status : null
83
+ }
84
+ const REDIRECT_CODES = new Set([301, 302, 303, 307, 308])
85
+ function safeLocation(value) {
86
+ 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')
87
+ return value
88
+ }
89
+ // `throw redirect('/login')` (or return it) from load/guards; same-site paths only.
76
90
  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 }
91
+ if (!REDIRECT_CODES.has(status)) throw new HttpError(500, 'Invalid redirect status')
92
+ return { redirect: safeLocation(url), status }
93
+ }
94
+ function mediaQuality(header, kind) {
95
+ let specificity = -1, quality = 0
96
+ for (const part of String(header).slice(0, 8192).split(',').slice(0, 64)) {
97
+ const [raw, ...params] = part.trim().split(';'), name = raw.toLowerCase()
98
+ const rank = name === kind ? 2 : name === kind.split('/')[0] + '/*' ? 1 : name === '*/*' ? 0 : -1
99
+ if (rank < 0) continue
100
+ let q = 1
101
+ for (const param of params) {
102
+ const [key, value] = param.trim().split('=')
103
+ if (key.toLowerCase() === 'q') { q = Number(value); if (!value || !Number.isFinite(q) || q < 0 || q > 1) q = 0 }
104
+ }
105
+ if (rank > specificity || rank === specificity && q > quality) { specificity = rank; quality = q }
106
+ }
107
+ return quality
108
+ }
109
+ function wantsHtml(req) {
110
+ const path = req.$brainPath || String(req.url || '/').split('?')[0]
111
+ if (/^\/(?:api|_brain|_r)(?:\/|$)/.test(path)) return false
112
+ const accept = req.headers.accept
113
+ if (!accept) return true
114
+ const quality = mediaQuality(accept, 'text/html')
115
+ return quality > 0 && quality >= mediaQuality(accept, 'application/json')
116
+ }
117
+ function defaultError(status, rid, home = '/') {
118
+ const title = STATUS_CODES[status] || 'Request failed'
119
+ 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>`
120
+ }
121
+
122
+ // Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli).
123
+ export function acceptedEncodings(header = '') {
124
+ const values = new Map()
125
+ for (const part of String(header).split(',')) {
126
+ const [name, ...params] = part.trim().split(';')
127
+ let q = 1
128
+ for (const p of params) {
129
+ const [k,v] = p.trim().split('=')
130
+ if (k && k.trim().toLowerCase() === 'q') { q = v && v.trim() ? Number(v) : NaN; if (!Number.isFinite(q) || q < 0 || q > 1) q = 0 }
131
+ }
132
+ values.set(name.toLowerCase(),q)
133
+ }
134
+ 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)
135
+ }
136
+
137
+ function googleSignInPage(page) {
138
+ let head = false, templates = 0
139
+ const tags = /<!--[\s\S]*?-->|<(script|style|title|noscript)\b(?:"[^"]*"|'[^']*'|[^'">])*?>[\s\S]*?<\/\1\s*>|<([^'">]|"[^"]*"|'[^']*')*>/gi
140
+ for (const match of page.matchAll(tags)) {
141
+ const tag = match[0]
142
+ if (tag.startsWith('<!--') || match[1]) continue
143
+ const name = /^<\/?([^\s/>]+)/.exec(tag)?.[1].toLowerCase(), closing = tag.startsWith('</')
144
+ if (name === 'head' && !templates) { if (closing) return false; head = true; continue }
145
+ if (name === 'body' && !templates) return false
146
+ if (name === 'template') { templates = Math.max(0, templates + (closing ? -1 : 1)); continue }
147
+ if (!closing && ['script', 'style', 'title', 'noscript'].includes(name)) return false
148
+ if (!head || templates || closing || name !== 'meta') continue
149
+ const attrs = new Map()
150
+ for (const item of tag.slice(5, -1).matchAll(/([^\s=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
151
+ const key = item[1].toLowerCase(); if (!attrs.has(key)) attrs.set(key, item[2] ?? item[3] ?? item[4] ?? '')
152
+ }
153
+ if (attrs.get('name')?.toLowerCase() === 'brain-google-signin' && attrs.get('content') === '1') return true
154
+ }
155
+ return false
156
+ }
157
+
158
+ // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
159
+ export function nativeAuthBridge(raw) {
160
+ if (!raw) return null
161
+ if (typeof raw !== 'string' || raw.length > 2048) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
162
+ let target
163
+ try { target = new URL(raw) } catch { throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin') }
164
+ 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')
165
+ if (target.hostname === 'localhost') target.hostname = '127.0.0.1'
166
+ const names = new Set(['__Host-brain', '__Host-brain-access', '__Host-brain-refresh'])
167
+ const agent = new HttpAgent({ keepAlive: true, maxSockets: 8, maxTotalSockets: 8, maxFreeSockets: 2, timeout: 30000 })
168
+ const owned = new Set()
169
+ let active = 0, stopped = false
170
+ const cookies = (req, includeRefresh) => {
171
+ const selected = new Map(), header = req.headers.cookie || ''
172
+ if (header.length > 16384) throw new HttpError(431, 'Authentication cookies exceed the request limit')
173
+ for (const part of header.split(';')) {
174
+ const at = part.indexOf('='); if (at < 0) continue
175
+ const name = part.slice(0, at).trim(), value = part.slice(at + 1).trim()
176
+ if (!names.has(name) || name === '__Host-brain-refresh' && !includeRefresh) continue
177
+ if (selected.has(name) || value.length > 8192 || !/^[a-zA-Z0-9._-]*$/.test(value)) throw new HttpError(400, 'Invalid authentication cookies')
178
+ selected.set(name, value)
179
+ }
180
+ return [...selected].map(([name, value]) => name + '=' + value).join('; ')
181
+ }
182
+ const safeUser = user => {
183
+ 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')
184
+ return Object.freeze({id:user.id,email:user.email,role:user.role,verified:user.verified})
185
+ }
186
+ const unavailable = () => new HttpError(503, 'Native authentication is temporarily unavailable')
187
+ const call = (incoming, response, path, method, rid, origin) => {
188
+ if (stopped || active >= 8) return Promise.reject(unavailable())
189
+ const cookie = cookies(incoming, method === 'POST')
190
+ active++
191
+ return new Promise((resolve, reject) => {
192
+ let outgoing, timer, finished = false
193
+ const finish = (error, value) => {
194
+ if (finished) return
195
+ finished = true; active--; clearTimeout(timer)
196
+ incoming.removeListener('aborted', cancel); response.removeListener('close', disconnected)
197
+ if (outgoing) owned.delete(outgoing)
198
+ error ? reject(unavailable()) : resolve(value)
199
+ }
200
+ const cancel = () => outgoing?.destroy(unavailable())
201
+ const disconnected = () => { if (!response.writableEnded) cancel() }
202
+ try {
203
+ 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 => {
204
+ let bytes = 0; const chunks = []
205
+ if (backend.headers['content-encoding'] || Number(backend.headers['content-length']) > 16384) { backend.destroy(); return finish(true) }
206
+ backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); finish(true) } else chunks.push(chunk) })
207
+ backend.on('error', () => finish(true))
208
+ backend.on('end', () => {
209
+ if (backend.statusCode === 401 && method === 'GET') return finish(false, { user: null })
210
+ if (backend.statusCode !== 200) return finish(true)
211
+ let payload
212
+ try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return finish(true) }
213
+ if (method === 'GET') {
214
+ try { return finish(false, {user:safeUser(payload?.user)}) } catch { return finish(true) }
215
+ }
216
+ if (payload?.ok !== true) return finish(true)
217
+ const cleared = []
218
+ for (const value of backend.headers['set-cookie'] || []) {
219
+ if (value.length > 8192 || /[\r\n]/.test(value)) return finish(true)
220
+ const name = value.split('=', 1)[0]
221
+ if (names.has(name) || name === '__Host-brain-google') cleared.push(value)
222
+ }
223
+ finish(false, { 'set-cookie': cleared, 'clear-site-data': '"storage"' })
224
+ })
225
+ })
226
+ owned.add(outgoing); incoming.once('aborted', cancel); response.once('close', disconnected)
227
+ timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
228
+ outgoing.once('error', () => finish(true)); outgoing.end()
229
+ } catch { outgoing?.destroy(); finish(true) }
230
+ })
231
+ }
232
+ let proxyBytes = 0
233
+ function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024) {
234
+ if (stopped || active >= 8) throw new HttpError(503, 'Backend request capacity is unavailable')
235
+ if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
236
+ const limit = url.pathname === '/_brain/uploads' && req.method === 'POST' ? 5 * 1024 * 1024 + 65536 : 65536
237
+ if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
238
+ active++
239
+ return new Promise((resolve, reject) => {
240
+ let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
241
+ const chunks = []
242
+ const headers = { 'x-request-id':rid }
243
+ 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']) {
244
+ const value = req.headers[key]
245
+ if (typeof value === 'string' && value.length <= 8192) headers[key] = value
246
+ }
247
+ const selected = []
248
+ for (const part of String(req.headers.cookie || '').split(';')) {
249
+ const name = part.trim().split('=')[0]
250
+ if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
251
+ }
252
+ if (selected.length) headers.cookie = selected.join('; ')
253
+ const finish = (error, backend) => {
254
+ if (done) return
255
+ done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
256
+ req.removeListener('aborted',cancel); req.removeListener('data',data); req.removeListener('end',end)
257
+ res.removeListener('close',cancel)
258
+ if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
259
+ const safe = { 'cache-control':'no-store' }
260
+ for (const key of ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data']) {
261
+ const value = backend.headers[key]
262
+ if (typeof value === 'string' && value.length <= 8192) safe[key] = value
263
+ }
264
+ const cookies = (backend.headers['set-cookie'] || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192)
265
+ if (cookies.length) safe['set-cookie'] = cookies.slice(0,8)
266
+ try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
267
+ }
268
+ const cancel = () => { outgoing?.destroy(); finish(true) }
269
+ const data = chunk => {
270
+ requestBytes += chunk.length
271
+ if (requestBytes > limit) { failure = new HttpError(413,'Request body is too large'); outgoing.destroy(); finish(true); return }
272
+ if (!outgoing.write(chunk)) { req.pause(); outgoing.once('drain',()=>req.resume()) }
273
+ }
274
+ const end = () => outgoing.end()
275
+ try {
276
+ outgoing = httpRequest(new URL(url.pathname + url.search,target),{agent,method:req.method,headers},backend => {
277
+ backend.on('data',chunk => {
278
+ bytes += chunk.length; proxyBytes += chunk.length
279
+ if (bytes > responseLimit || proxyBytes > 64 * 1024 * 1024) { failure = new HttpError(502,'Backend response exceeds its byte budget'); outgoing.destroy(); finish(true); return }
280
+ chunks.push(chunk)
281
+ })
282
+ backend.once('end',()=>finish(false,backend)); backend.once('error',()=>finish(true)); backend.once('aborted',()=>finish(true))
283
+ })
284
+ owned.add(outgoing)
285
+ 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)
286
+ outgoing.once('error',()=>finish(true)); req.once('aborted',cancel); res.once('close',cancel)
287
+ req.on('data',data); req.once('end',end)
288
+ if (req.readableEnded) end()
289
+ } catch { outgoing?.destroy(); finish(true) }
290
+ })
291
+ }
292
+ const resource = (incoming, response, rid, name) => {
293
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
294
+ const idPath = id => {
295
+ 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')
296
+ return '/api/' + name + '/' + id
297
+ }
298
+ const request = async (method, path, body) => {
299
+ if (response.writableEnded || response.destroyed) throw new HttpError(409, 'The server request has ended')
300
+ if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
301
+ let bytes = Buffer.alloc(0)
302
+ if (body !== undefined) {
303
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'Native resource values must be an object')
304
+ try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
305
+ if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
306
+ }
307
+ const req = Readable.from(bytes.length ? [bytes] : [])
308
+ req.method = method
309
+ req.headers = { cookie: cookies(incoming, false), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
310
+ if (method !== 'GET') {
311
+ let origin
312
+ 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') }
313
+ req.headers.origin = origin.origin
314
+ }
315
+ let value
316
+ try {
317
+ await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
318
+ if (status < 200 || status >= 300) throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed')
319
+ if (status === 204) { value = null; return }
320
+ if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
321
+ try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
322
+ }, 2 * 1024 * 1024)
323
+ } finally { req.destroy() }
324
+ if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
325
+ return value
326
+ }
327
+ return Object.freeze({
328
+ list(options = {}) {
329
+ 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')
330
+ const {search = '', limit = 20, offset = 0} = options
331
+ 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')
332
+ return request('GET', '/api/' + name + '?' + new URLSearchParams({search,limit:String(limit),offset:String(offset)}))
333
+ },
334
+ get: id => request('GET', idPath(id)),
335
+ create: values => request('POST', '/api/' + name, values),
336
+ update: (id, values) => request('PUT', idPath(id), values),
337
+ remove: id => request('DELETE', idPath(id)),
338
+ })
339
+ }
340
+ async function login(incoming,response,rid,credentials) {
341
+ if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
342
+ if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
343
+ 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')
344
+ let origin
345
+ 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')}
346
+ incoming.$nativeLoginStarted=true
347
+ const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
348
+ 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)}
349
+ let user
350
+ try {
351
+ await proxy(request,response,new URL('/_brain/auth/login',target),rid,(_res,status,data,headers)=>{
352
+ 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')
353
+ let payload
354
+ try {payload=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
355
+ user=safeUser(payload?.user)
356
+ const selected=headers['set-cookie']||[]
357
+ if(new Set(selected.map(value=>value.split('=',1)[0])).size!==selected.length)throw unavailable()
358
+ 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()
359
+ if(response.writableEnded||response.destroyed)throw unavailable()
360
+ incoming.$nativeLoginHeaders={'set-cookie':selected,'cache-control':'no-store'}
361
+ },16384)
362
+ } finally {request.destroy();bytes.fill(0)}
363
+ return {user}
364
+ }
365
+ return {
366
+ proxy,
367
+ resource,
368
+ login,
369
+ async prepare(req, res, rid) {
370
+ if (!cookies(req, false)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
371
+ if (!req.$nativeAuth) req.$nativeAuth = call(req, res, '/_brain/auth/me', 'GET', rid).then(result => { req.$nativeUser = result.user })
372
+ await req.$nativeAuth
373
+ },
374
+ session(req, res, rid) {
375
+ return {
376
+ user: req.$nativeUser,
377
+ set() { throw new HttpError(409, 'Use the native authentication API to establish a session') },
378
+ clear() {
379
+ if (req.$nativeLoginStarted) throw new HttpError(409,'Use a separate server request to sign out after signing in')
380
+ if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'Sign out through a same-origin POST request')
381
+ let origin
382
+ 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') }
383
+ if (!req.$nativeLogout) {
384
+ 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 })
385
+ req.$nativeLogout.catch(() => {})
386
+ }
387
+ return req.$nativeLogout
388
+ },
389
+ headers: () => req.$nativeLoginHeaders || {},
390
+ }
391
+ },
392
+ close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy() },
393
+ }
80
394
  }
81
395
 
82
396
  export function createServer(o = {}) {
83
397
  const dist = resolve(o.dist || process.cwd())
84
398
  const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
399
+ const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL)
85
400
  const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
86
401
  const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
87
402
  const MAX_BODY = 64 * 1024
88
403
  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 })))
404
+ const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
405
+ const log = (level, msg, extra = {}) => {
406
+ const safe = { ...extra }
407
+ // Error messages may contain credentials or request data. Retain only bounded V8 source frames.
408
+ if (typeof safe.stack === 'string') {
409
+ const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
410
+ safe.errorType = kind
411
+ safe.code = kind === 'Error' ? 'NODE_INTERNAL_ERROR' : 'NODE_' + kind.replace(/Error$/, '').toUpperCase() + '_ERROR'
412
+ const truncated = safe.stack.length > 32768 || safe.stack.split('\n').length > 41
413
+ safe.traceTruncated = truncated
414
+ 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')
415
+ safe.private = true; safe.valuesCaptured = false
416
+ 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'
417
+ }
418
+ if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
419
+ sink(level, msg, safe)
420
+ }
90
421
  const mf = readManifest(dist)
91
422
  // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
92
423
  const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
@@ -112,9 +443,41 @@ export function createServer(o = {}) {
112
443
  hook(distURL, BASE)
113
444
 
114
445
  // ---------- helpers ----------
115
- const send = (res, status, body, headers = {}) => {
446
+ const send = (res, status, body, headers = {}, configuredExternal = false) => {
447
+ if (native && res.req.$nativeLogout && !res.req.$nativeLogoutDone) {
448
+ res.req.$nativeLogout.then(() => send(res, status, body, headers, configuredExternal), () => send(res, 503, '', {}, false))
449
+ return
450
+ }
451
+ if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false }
452
+ if (native && res.req.$nativeLogoutHeaders) {
453
+ const relay = res.req.$nativeLogoutHeaders, own = headers['set-cookie']
454
+ headers = { ...headers, ...relay, 'set-cookie': [...(own ? Array.isArray(own) ? own : [own] : []), ...relay['set-cookie']] }
455
+ }
456
+ if (!Number.isInteger(status) || status < 200 || status > 599 || !STATUS_CODES[status]) throw new HttpError(500, 'Invalid final response status')
457
+ if (headers.location != null) {
458
+ if (configuredExternal && /^https?:\/\//.test(headers.location)) {
459
+ const target = new URL(headers.location)
460
+ if (target.username || target.password || /[\\\x00-\x20\x7f]/.test(headers.location)) throw new HttpError(500, 'Invalid configured redirect location')
461
+ } else safeLocation(headers.location)
462
+ }
463
+ if (status >= 400) {
464
+ headers = { ...headers, 'cache-control': 'private, no-store', vary: [headers.vary, 'Accept'].filter(Boolean).join(', ') }
465
+ const kind = headers['content-type'] || ''
466
+ if (!kind || kind.startsWith('text/plain')) {
467
+ const rid = res.getHeader('x-request-id') || ''
468
+ if (wantsHtml(res.req)) {
469
+ body = defaultError(status, rid, BASE)
470
+ headers['content-type'] = MIME['.html']
471
+ headers['content-security-policy'] = mf.csp || CSP0
472
+ } else {
473
+ body = json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' })
474
+ headers['content-type'] = MIME['.json']
475
+ }
476
+ delete headers['content-length']; delete headers['content-encoding']; delete headers.etag
477
+ }
478
+ }
116
479
  res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
117
- res.end(body)
480
+ res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
118
481
  }
119
482
  // HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
120
483
  // taken before the per-response nonce is injected, so it is stable
@@ -126,20 +489,21 @@ export function createServer(o = {}) {
126
489
  }
127
490
  const nonce = randomBytes(16).toString('base64')
128
491
  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 })
492
+ 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
493
  }
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>')
494
+ const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
495
+ const failPage = (res, status, rid) => {
496
+ if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
497
+ const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || `${status}.html`
498
+ const path = typeof custom === 'string' && file(custom)
499
+ return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
135
500
  }
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
501
  // a file inside dist/ (never outside: no `..`, no absolute paths)
139
502
  function file(rel) {
140
503
  const p = normalize(join(dist, rel))
141
504
  if (p !== dist && !p.startsWith(dist + sep)) return null
142
505
  try {
506
+ for (let cursor = p; cursor !== dist; cursor = dirname(cursor)) if (lstatSync(cursor).isSymbolicLink()) return null
143
507
  return statSync(p).isFile() ? p : null
144
508
  } catch {
145
509
  return null
@@ -154,9 +518,10 @@ export function createServer(o = {}) {
154
518
 
155
519
  const context = (req, res, url, extra) => {
156
520
  const cookies = parseCookies(req.headers.cookie)
157
- const { user, session: ses } = sessionFrom(cookies, secret)
521
+ const { user, session: ses } = native ? { user: req.$nativeUser ?? null, session: native.session(req, res, res.getHeader('x-request-id')) } : sessionFrom(cookies, secret)
158
522
  return {
159
523
  url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
524
+ ...(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
525
  // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
161
526
  prefetch: req.headers['x-brain-prefetch'] === '1',
162
527
  req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
@@ -221,10 +586,11 @@ export function createServer(o = {}) {
221
586
  } catch {}
222
587
  }
223
588
  if (!nocache && route.cache > 0 && !forms && !fixed && status === 200 && anonymous(req)) return cached(req, res, route, params, url, rid)
589
+ if (native) await native.prepare(req, res, rid)
224
590
  const ctx = context(req, res, url, { params })
225
591
  // a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
226
592
  // 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
593
+ const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
228
594
  const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
229
595
  let props = fixed || {}, extra = {}
230
596
  if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
@@ -238,7 +604,7 @@ export function createServer(o = {}) {
238
604
  log('error', 'guard failed', { rid, path: url.pathname, stack: String((e && e.stack) || e) })
239
605
  return errorPage(req, res, route, params, url, rid)
240
606
  }
241
- if (r.redirect) return send(res, r.status, '', { location: withBase(r.redirect), 'cache-control': 'no-store', ...ctx.session.headers() })
607
+ 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
608
  if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
243
609
  extra = r.headers
244
610
  }
@@ -249,13 +615,13 @@ export function createServer(o = {}) {
249
615
  if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
250
616
  }
251
617
  } catch (e) {
252
- if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(e.redirect), ...ctx.session.headers() })
618
+ if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
253
619
  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
620
+ const status = intendedStatus(e) || 500
255
621
  log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
256
622
  return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
257
623
  }
258
- if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(props.redirect), ...ctx.session.headers() })
624
+ if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(redirect(props.redirect, props.status ?? 303).redirect), ...ctx.session.headers() })
259
625
  if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
260
626
  let body, seo, failed
261
627
  try {
@@ -333,7 +699,7 @@ export function createServer(o = {}) {
333
699
  props = (await loadP) ?? {}
334
700
  if (props.redirect || props.notFound === true) throw props
335
701
  } catch (e) {
336
- if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(e.redirect))})</script>`)
702
+ if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(redirect(e.redirect, e.status ?? 303).redirect))})</script>`)
337
703
  // a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
338
704
  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
705
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, stack: String((e && e.stack) || e) }) // headers already out
@@ -362,14 +728,15 @@ export function createServer(o = {}) {
362
728
  }
363
729
  // deny → views/403.brain (manifest `forbidden`, props.reason) or the static 403.html, always status 403
364
730
  function forbidden(req, res, url, rid, reason) {
731
+ if (!wantsHtml(req)) return failPage(res, 403, rid)
365
732
  if (mf.forbidden) return ssr(req, res, mf.forbidden, {}, url, rid, null, 403, { reason })
366
733
  const f = file('403.html')
367
734
  if (f) return html(res, 403, readFileSync(f, 'utf8'))
368
- send(res, 403, 'Forbidden', { 'content-type': MIME['.txt'] })
735
+ return failPage(res, 403, rid)
369
736
  }
370
737
  // a failing load or guard → the folder's _error.brain (route `error`, props { message, rid }), else the generic page
371
738
  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 })
739
+ if (wantsHtml(req) && route.error) return ssr(req, res, route.error, params, url, rid, null, 500, { message: 'Something went wrong.', rid })
373
740
  return failPage(res, 500, rid)
374
741
  }
375
742
  async function render(route, props, forms, at = null) {
@@ -437,6 +804,7 @@ export function createServer(o = {}) {
437
804
  } catch {
438
805
  return fail(413, 'Body larger than 64 KB')
439
806
  }
807
+ if (native) await native.prepare(req, res, rid)
440
808
  const ctx = context(req, res, url, { params: {} })
441
809
  let f
442
810
  try {
@@ -478,6 +846,7 @@ export function createServer(o = {}) {
478
846
  }
479
847
  const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
480
848
  if (!modPath) return plain(400, 'Unknown form')
849
+ if (native) await native.prepare(req, res, rid)
481
850
  const ctx = context(req, res, url, { params })
482
851
  let module, rules
483
852
  try {
@@ -573,15 +942,27 @@ export function createServer(o = {}) {
573
942
  // ---------- static ----------
574
943
  // §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
575
944
  // `/` → index.md) when the build published one (`seo.markdown`, default true; never for noindex pages).
945
+ function publicStaticPath(pathname) {
946
+ let path
947
+ try { path = decodeURIComponent(pathname) } catch { return null }
948
+ if (/[\\\x00]/.test(path)) return null
949
+ const clean = normalize(path).replaceAll('\\', '/')
950
+ const rel = clean.replace(/^\/+/, '')
951
+ 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
952
+ return path
953
+ }
576
954
  function serveTwin(req, res, url) {
577
955
  if (!prefersMarkdown(req.headers.accept)) return false
578
- const md = file(mdPath(decodeURIComponent(url.pathname)))
956
+ const path = publicStaticPath(url.pathname)
957
+ if (path === null) return false
958
+ const md = file(mdPath(path))
579
959
  if (!md) return false
580
960
  send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
581
961
  return true
582
962
  }
583
963
  function serveStatic(req, res, url) {
584
- let p = decodeURIComponent(url.pathname)
964
+ const p = publicStaticPath(url.pathname)
965
+ if (p === null) return false
585
966
  const candidates = p.endsWith('/') ? [p + 'index.html'] : [p, p + '.html', p + '/index.html']
586
967
  for (const c of candidates) {
587
968
  const f = file(c)
@@ -593,7 +974,7 @@ export function createServer(o = {}) {
593
974
  const md = file(mdPath(p))
594
975
  const page = readFileSync(f, 'utf8')
595
976
  // 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
977
+ return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
597
978
  }
598
979
  const headers = {
599
980
  'content-type': type,
@@ -601,10 +982,11 @@ export function createServer(o = {}) {
601
982
  'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
602
983
  vary: 'accept-encoding',
603
984
  }
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 })
985
+ for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
986
+ const suffix = enc === 'br' ? '.br' : '.gz'
987
+ const encoded = file(c + suffix)
988
+ if (encoded) {
989
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(encoded), { ...headers, 'content-encoding': enc })
608
990
  return true
609
991
  }
610
992
  }
@@ -621,7 +1003,7 @@ export function createServer(o = {}) {
621
1003
  const t0 = performance.now()
622
1004
  res.on('finish', () => {
623
1005
  const ms = performance.now() - t0
624
- if (ms > 1000) log('warn', 'slow request', { rid, path: req.url, status: res.statusCode, ms: Math.round(ms) })
1006
+ if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
625
1007
  })
626
1008
  try {
627
1009
  const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
@@ -633,12 +1015,14 @@ export function createServer(o = {}) {
633
1015
  if (!full.pathname.startsWith(BASE)) return notFound(req, res)
634
1016
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
635
1017
  }
1018
+ req.$brainPath = url.pathname
1019
+ if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
636
1020
  // redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
637
1021
  // same-site targets get the base, the query is kept when the target has none
638
1022
  const to = redirectOf(url.pathname)
639
1023
  if (to && (req.method === 'GET' || req.method === 'HEAD')) {
640
1024
  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' })
1025
+ return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' }, true)
642
1026
  }
643
1027
  const r = /^\/_r\/([\w-]+)\/([\w$]+)$/.exec(url.pathname)
644
1028
  if (r) return await rpc(req, res, r[1], r[2], url, rid)
@@ -660,10 +1044,11 @@ export function createServer(o = {}) {
660
1044
  notFound(req, res)
661
1045
  } catch (e) {
662
1046
  log('error', 'unhandled', { rid, path: req.url, stack: String((e && e.stack) || e) })
663
- if (!res.headersSent) failPage(res, 500, rid)
1047
+ if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
664
1048
  else res.end()
665
1049
  }
666
1050
  })
1051
+ if (native) server.once('close', () => native.close())
667
1052
  server.headersTimeout = 15000
668
1053
  server.requestTimeout = 30000
669
1054
  return server
@@ -719,14 +1104,14 @@ export function sessionFrom(cookies, secret) {
719
1104
  // `_middleware.jsbrain`: async (ctx, next) => …; may set ctx.headers (response headers) or stop with redirect/deny.
720
1105
  // The same shapes come out of brain-router's redirect()/deny() in the browser.
721
1106
  export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
722
- const samePath = u => typeof u === 'string' && u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')
1107
+ const samePath = u => { try { safeLocation(u); return true } catch { return false } }
723
1108
  function verdict(v) {
724
1109
  if (v === false) return { deny: 'Forbidden' }
725
1110
  if (typeof v === 'string') v = { redirect: v }
726
1111
  if (!v || typeof v !== 'object') return null
727
1112
  if (v.redirect !== undefined) {
728
1113
  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 }
1114
+ return redirect(v.redirect, v.status ?? 303)
730
1115
  }
731
1116
  return v.deny !== undefined ? { deny: String(v.deny) } : null
732
1117
  }
@@ -811,7 +1196,9 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
811
1196
  } catch (e) {
812
1197
  if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
813
1198
  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 } }
1199
+ const deliberate = intendedStatus(e)
1200
+ if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError ? e.message : STATUS_CODES[deliberate] } }
1201
+ if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
815
1202
  return { status: 500, body: { error: 'Internal error' }, cause: e }
816
1203
  }
817
1204
  }
@@ -835,10 +1222,13 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
835
1222
  } catch (e) {
836
1223
  if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
837
1224
  if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
1225
+ const deliberate = intendedStatus(e)
1226
+ if (deliberate && deliberate < 500) return {status:deliberate,body:{error:STATUS_CODES[deliberate]}}
1227
+ if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
838
1228
  return again({}, 'Something went wrong. Try again.', e)
839
1229
  }
840
1230
  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
1231
+ const to = r && r.redirect != null ? safeLocation(r.redirect) : ctx.url.pathname + ctx.url.search
842
1232
  return { status: 303, location: to }
843
1233
  }
844
1234
 
@@ -852,7 +1242,15 @@ function coerce(F, schema, q, base = '', values = {}, errors = {}) {
852
1242
  coerce(F, r.s, q, path, values[k], errors)
853
1243
  continue
854
1244
  }
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)
1245
+ const one = v => {
1246
+ if (r.t === 'num') {
1247
+ if (v == null || v.trim() === '') return null
1248
+ // Validate the original decimal token before conversion; Number('0x10') hides invalid form input.
1249
+ return F.check({ ...r, list: false, req: false, min: null, max: null }, v) ? v : Number(v)
1250
+ }
1251
+ if (r.t === 'bool') return v == null || v === '' || v === 'false' || v === '0' ? false : v === 'on' || v === 'true' || v === '1' ? true : v
1252
+ return v == null ? '' : v
1253
+ }
856
1254
  const v = r.list ? q.getAll(path).map(one) : r.t === 'bool' ? one(q.get(path)) : one(q.get(path) ?? '')
857
1255
  values[k] = v
858
1256
  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 +1413,7 @@ function real(p) {
1015
1413
  }
1016
1414
  }
1017
1415
  if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
1416
+ process.env.NODE_ENV = 'production'
1018
1417
  const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
1019
1418
  const fd = ENV.BRAIN_LISTEN_FD
1020
1419
  const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }