@christianirack/brainllm-runtime 0.6.0 → 0.7.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
@@ -18,11 +18,13 @@
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
20
  import { createServer as http, STATUS_CODES, request as httpRequest, Agent as HttpAgent } from 'node:http'
21
+ import { AsyncLocalStorage } from 'node:async_hooks'
21
22
  import net from 'node:net'
22
23
  import { installNativeSockets } from './native-sockets.mjs'
24
+ import { BlockList, isIP } from 'node:net'
23
25
  import { Readable } from 'node:stream'
24
26
  import zlib from 'node:zlib'
25
- import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
27
+ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
26
28
  import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
27
29
  import * as Module from 'node:module'
28
30
  import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
@@ -47,7 +49,7 @@ const SECURITY = {
47
49
  'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
48
50
  'cross-origin-opener-policy': 'same-origin',
49
51
  // what serves the app and where it lives, for crawlers and LLMs that read headers (no version: that stays in the HTML)
50
- 'x-powered-by': 'BRAINLLM (https://chrisifm.github.io/BRAINLLM/)',
52
+ 'x-generated-by': 'BRAINLLM (https://chrisifm.github.io/BRAINLLM/; https://github.com/chrisifm/BRAINLLM)',
51
53
  }
52
54
  // The build's policy (manifest `csp`, without hashes: the same one static hosting uses) plus this response's nonce in
53
55
  // script-src; without it, a default that lets api() reach HTTPS APIs of other origins.
@@ -68,7 +70,7 @@ const b64u = b => Buffer.from(b).toString('base64url')
68
70
  const tag = s => createHash('sha1').update(s).digest('base64url').slice(0, 22)
69
71
 
70
72
  // Errors a server function may throw on purpose: { errors: { field: msg } } → 422; { status, message } with 4xx → that
71
- const LIST_OPTIONS = ['search','limit','offset','filter','sort','cursor','after','include','aggregate','count']
73
+ const LIST_OPTIONS = ['search','limit','offset','filter','sort','cursor','after','include','aggregate','count','fields','owner']
72
74
  const FILTER_OPS = ['eq','ne','gt','gte','lt','lte','between','in','exists','prefix','iprefix','has']
73
75
  export class HttpError extends Error {
74
76
  constructor(status, message, extra) {
@@ -103,6 +105,24 @@ function safeLocation(value) {
103
105
  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')
104
106
  return value
105
107
  }
108
+ // Internal links keep the page's language (§37): `route.hrefs` is the table of a page outside the default language, the
109
+ // one brain-links.js applies in the browser; `b` is the base path, added later here.
110
+ function linkTo(route, path) {
111
+ const t = route && route.hrefs
112
+ if (!t || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path
113
+ const m = /^[^?#]*/.exec(path)[0]
114
+ const n = t.s[m] || (new RegExp(t.re).test(m) && (m === '/' ? t.p : t.p + m))
115
+ return n ? n + path.slice(m.length) : path
116
+ }
117
+ function localizeLinks(route, html) {
118
+ if (!route.hrefs) return html
119
+ const b = (globalThis.__brainBase || '/').slice(0, -1) // render() set it for this page
120
+ return html.replace(/<(?:a|form)\s[^>]*>/g, tag => /\shreflang=/.test(tag) ? tag : tag.replace(/(\s(?:href|action)=")(\/[^"]*)"/, (all, at, h) => {
121
+ const a = b && (h + '/').startsWith(b + '/') ? h.slice(b.length) || '/' : h
122
+ return at + (a === h ? linkTo(route, a) : b + linkTo(route, a)) + '"'
123
+ }))
124
+ }
125
+
106
126
  // `throw redirect('/login')` (or return it) from load/guards; same-site paths only.
107
127
  export const redirect = (url, status = 303) => {
108
128
  if (!REDIRECT_CODES.has(status)) throw new HttpError(500, 'Invalid redirect status')
@@ -187,8 +207,64 @@ function googleSignInPage(page) {
187
207
  }
188
208
 
189
209
  // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
190
- export function nativeAuthBridge(raw) {
210
+ // ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
211
+ function bucketClient(target, key, bucket) {
212
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
213
+ if (typeof bucket !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(bucket)) throw new HttpError(400, 'Invalid native bucket name')
214
+ const base = '/_brain/files/' + bucket
215
+ const idPath = id => { if (typeof id !== 'string' || !UUID.test(id)) throw new HttpError(404, 'No file has this id; file ids are UUIDs'); return base + '/' + id }
216
+ const send = async (method, path, body, timeout) => {
217
+ let response
218
+ try { response = await fetch(new URL(path, target), { method, redirect: 'error', signal: AbortSignal.timeout(timeout), headers: { 'x-api-key': key }, body }) } catch { throw new HttpError(503, 'Native backend is unavailable') }
219
+ if (response.ok) return response
220
+ await response.body?.cancel()
221
+ throw new HttpError(response.status >= 400 && response.status < 500 ? response.status : 503, 'Native file request failed')
222
+ }
223
+ const json = async response => { try { return await response.json() } catch { throw new HttpError(502, 'Native file response is invalid') } }
224
+ return Object.freeze({
225
+ async list(options = {}) {
226
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(k => !['limit', 'offset', 'record'].includes(k))) throw new HttpError(400, 'Invalid native file list options')
227
+ const { limit, offset, record } = options
228
+ if ((limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) || (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000)) || (record !== undefined && !UUID.test(String(record)))) throw new HttpError(400, 'Invalid native file list options')
229
+ const q = new URLSearchParams(Object.entries({ limit, offset, record }).filter(([, v]) => v !== undefined))
230
+ return json(await send('GET', base + (q.size ? '?' + q : ''), undefined, 10000))
231
+ },
232
+ async meta(id) { return json(await send('GET', idPath(id) + '/meta', undefined, 10000)) },
233
+ async read(id, { maxBytes = 16 * 1024 * 1024 } = {}) {
234
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024) throw new HttpError(400, 'Invalid native file read limit')
235
+ const response = await send('GET', idPath(id), undefined, 30000)
236
+ if (Number(response.headers.get('content-length')) > maxBytes) { await response.body?.cancel(); throw new HttpError(413, 'The file exceeds the read limit; raise maxBytes up to 64 MiB') }
237
+ const bytes = Buffer.from(await response.arrayBuffer())
238
+ if (bytes.length > maxBytes) throw new HttpError(413, 'The file exceeds the read limit; raise maxBytes up to 64 MiB')
239
+ return { bytes, contentType: response.headers.get('content-type'), etag: response.headers.get('etag') }
240
+ },
241
+ async upload({ filename, contentType = 'application/octet-stream', data, record } = {}) {
242
+ if (typeof filename !== 'string' || !filename || Buffer.byteLength(filename) > 255 || typeof contentType !== 'string' || (record !== undefined && !UUID.test(String(record)))) throw new HttpError(400, 'Invalid native file upload')
243
+ if (typeof data !== 'string' && !(data instanceof Uint8Array)) throw new HttpError(400, 'File data must be a string, Buffer or Uint8Array')
244
+ const form = new FormData()
245
+ form.set('file', new Blob([data], { type: contentType }), filename)
246
+ return json(await send('POST', base + (record === undefined ? '' : '?record=' + record), form, 60000))
247
+ },
248
+ async remove(id) { await send('DELETE', idPath(id), undefined, 10000); return null },
249
+ })
250
+ }
251
+ export function nativeAuthBridge(raw, refused, serviceKey, report) {
252
+ // How to fix the configuration is for the operator: a visitor of a production app reads a generic message.
253
+ const configuration = (status, detail, rid, shown = 'This service is not available right now') => {
254
+ if (process.env.NODE_ENV !== 'production') return new HttpError(status, detail)
255
+ report?.('error', 'native backend configuration', { rid, fix: detail })
256
+ return new HttpError(status, shown)
257
+ }
191
258
  if (!raw) return null
259
+ // The visitor's cookies only travel for a request from the site itself; the caller's log gets the reason (B0742).
260
+ const bridgeOrigin = (incoming, rid, message) => {
261
+ try {
262
+ const origin = new URL(incoming.headers.origin)
263
+ if (origin.origin === incoming.headers.origin && origin.host === (incoming.headers['x-forwarded-host'] || incoming.headers.host)) return origin.origin
264
+ } catch {}
265
+ if (refused) refused('B0742', incoming, rid, 'native')
266
+ throw new HttpError(403, 'B0742: ' + message)
267
+ }
192
268
  if (typeof raw !== 'string' || raw.length > 2048) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
193
269
  let target
194
270
  try { target = new URL(raw) } catch { throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin') }
@@ -218,6 +294,7 @@ export function nativeAuthBridge(raw) {
218
294
  const call = (incoming, response, path, method, rid, origin) => {
219
295
  if (stopped || active >= 8) return Promise.reject(unavailable())
220
296
  const cookie = cookies(incoming, method === 'POST')
297
+ const client = forwardedFor(incoming)
221
298
  active++
222
299
  return new Promise((resolve, reject) => {
223
300
  let outgoing, timer, finished = false
@@ -231,7 +308,7 @@ export function nativeAuthBridge(raw) {
231
308
  const cancel = () => outgoing?.destroy(unavailable())
232
309
  const disconnected = () => { if (!response.writableEnded) cancel() }
233
310
  try {
234
- 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 => {
311
+ outgoing = httpRequest(new URL(path, target), { method, agent, headers: { accept: 'application/json', ...(cookie ? { cookie } : {}), 'x-request-id': rid, ...(client ? { 'x-forwarded-for': client } : {}), ...(origin ? { origin } : {}), ...(method === 'POST' ? { 'content-length': '0' } : {}) } }, backend => {
235
312
  let bytes = 0; const chunks = []
236
313
  if (backend.headers['content-encoding'] || Number(backend.headers['content-length']) > 16384) { backend.destroy(); return finish(true) }
237
314
  backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); finish(true) } else chunks.push(chunk) })
@@ -260,27 +337,136 @@ export function nativeAuthBridge(raw) {
260
337
  } catch { outgoing?.destroy(); finish(true) }
261
338
  })
262
339
  }
263
- let proxyBytes = 0
264
- function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024) {
340
+ // The access cookie lives 5 minutes and the session far longer: a password JWT session would read as anonymous on the
341
+ // next page. The backend answers concurrent refreshes with the same token inside its grace window, so tabs and
342
+ // prefetches cannot revoke the family. A denied refresh is anonymous; an unavailable backend stays an error.
343
+ const renew = (req, res, rid) => {
344
+ const selected = cookies(req, true)
345
+ let origin
346
+ try { origin = new URL(process.env.PUBLIC_SITE_URL).origin } catch { origin = 'https://' + (req.headers['x-forwarded-host'] || req.headers.host) }
347
+ return new Promise((resolve, reject) => {
348
+ if (stopped || active >= 8) return reject(unavailable())
349
+ active++
350
+ let settled = false
351
+ const done = (error, value) => { if (settled) return; settled = true; active--; clearTimeout(timer); error ? reject(unavailable()) : resolve(value) }
352
+ const outgoing = httpRequest(new URL('/_brain/auth/refresh', target), { method: 'POST', agent, headers: { accept: 'application/json', cookie: selected, origin, 'x-request-id': rid, 'content-length': '0' } }, backend => {
353
+ let bytes = 0; const chunks = []
354
+ backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); done(true) } else chunks.push(chunk) })
355
+ backend.on('error', () => done(true))
356
+ backend.on('end', () => {
357
+ if (backend.statusCode === 401 || backend.statusCode === 403) return done(false)
358
+ if (backend.statusCode !== 200) return done(true)
359
+ try {
360
+ const user = safeUser(JSON.parse(Buffer.concat(chunks).toString('utf8'))?.user)
361
+ const set = (backend.headers['set-cookie'] || []).filter(value => /^__Host-brain-(?:access|refresh)=/.test(value) && value.length <= 8192 && !/[\r\n]/.test(value))
362
+ if (!set.some(value => value.startsWith('__Host-brain-access='))) return done(true)
363
+ req.$nativeUser = user
364
+ req.$nativeLoginHeaders = { 'set-cookie': set, 'cache-control': 'no-store' }
365
+ // later backend calls of this same request use the new tokens
366
+ const fresh = new Map(set.map(value => { const pair = value.split(';', 1)[0], at = pair.indexOf('='); return [pair.slice(0, at), pair.slice(at + 1)] }))
367
+ const kept = (req.headers.cookie || '').split(';').map(part => part.trim()).filter(part => part && !fresh.has(part.split('=', 1)[0]))
368
+ req.headers.cookie = [...kept, ...[...fresh].map(([name, value]) => name + '=' + value)].join('; ')
369
+ done(false)
370
+ } catch { done(true) }
371
+ })
372
+ })
373
+ const timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
374
+ owned.add(outgoing); outgoing.once('close', () => owned.delete(outgoing))
375
+ outgoing.once('error', () => done(true)); outgoing.end()
376
+ })
377
+ }
378
+ let proxyBytes = 0, streams = 0
379
+ // A file transfer lasts as long as the client needs, so streams have their own sockets and budget and never hold API slots.
380
+ const streamAgent = new HttpAgent({ keepAlive: false, maxSockets: 32, maxTotalSockets: 32 })
381
+ const REQUEST_HEADERS = ['origin','authorization','x-api-key','content-type','content-length','x-brain-rpc','accept','if-match','if-none-match','x-brain-webhook-id','x-brain-webhook-timestamp','x-brain-webhook-signature','user-agent','dnt','sec-gpc','cf-ipcountry']
382
+ const FILE_HEADERS = ['range','if-range','if-modified-since','if-unmodified-since']
383
+ const RESPONSE_HEADERS = ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data','cache-control']
384
+ const FILE_RESPONSE_HEADERS = ['content-length','content-range','accept-ranges','vary','content-security-policy','x-content-type-options','cross-origin-resource-policy','referrer-policy']
385
+ // The identity the backend resolves through trustProxy: the hops already received plus the peer this gateway observed.
386
+ // Hops to the left of an untrusted peer are ignored by the backend, so a client header can never choose the identity.
387
+ const forwardedFor = req => {
388
+ if (req.$brainForwardedFor) return req.$brainForwardedFor
389
+ const peer = req.socket?.remoteAddress
390
+ if (!peer) return undefined
391
+ const prior = req.headers['x-forwarded-for']
392
+ return typeof prior === 'string' && prior.length <= 4096 ? prior + ', ' + peer : peer
393
+ }
394
+ const requestHeaders = (req, rid, extra = []) => {
395
+ const headers = { 'x-request-id':rid }
396
+ for (const key of [...REQUEST_HEADERS, ...extra]) {
397
+ const value = req.headers[key]
398
+ if (typeof value === 'string' && value.length <= 8192) headers[key] = value
399
+ }
400
+ const selected = []
401
+ for (const part of String(req.headers.cookie || '').split(';')) {
402
+ const name = part.trim().split('=')[0]
403
+ if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
404
+ }
405
+ if (selected.length) headers.cookie = selected.join('; ')
406
+ const client = forwardedFor(req)
407
+ if (client) headers['x-forwarded-for'] = client
408
+ return headers
409
+ }
410
+ const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
411
+ // /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
412
+ function fileStream(req, res, url, rid) {
413
+ if (stopped || streams >= 32) throw new HttpError(503, 'Backend stream capacity is unavailable')
414
+ if (!['GET','HEAD','POST','DELETE'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
415
+ const ceiling = 2 * 1024 * 1024 * 1024 + 1024 * 1024
416
+ if (Number(req.headers['content-length'] || 0) > ceiling) throw new HttpError(413, 'Request body is too large')
417
+ clearTimeout(req.$brainBodyGuard)
418
+ streams++
419
+ return new Promise((resolve, reject) => {
420
+ let outgoing, deadline, done = false, sent = 0
421
+ const finish = failure => {
422
+ if (done) return
423
+ done = true; streams--; clearTimeout(deadline); owned.delete(outgoing)
424
+ req.unpipe(outgoing); req.removeListener('data', count); res.removeListener('close', closed)
425
+ if (failure && !res.headersSent && !res.destroyed) return reject(failure)
426
+ if (failure && !res.destroyed) res.destroy()
427
+ outgoing?.destroy(); resolve()
428
+ }
429
+ const closed = () => finish()
430
+ const count = chunk => { sent += chunk.length; if (sent > ceiling) finish(new HttpError(413, 'Request body is too large')) }
431
+ const unavailable = () => new HttpError(503, 'Backend request unavailable')
432
+ try {
433
+ outgoing = httpRequest(new URL(url.pathname + url.search, target), { agent:streamAgent, method:req.method, headers:requestHeaders(req, rid, FILE_HEADERS) }, backend => {
434
+ const safe = { 'cache-control':'no-store' }
435
+ for (const key of [...RESPONSE_HEADERS, ...FILE_RESPONSE_HEADERS]) {
436
+ const value = backend.headers[key]
437
+ if (typeof value === 'string' && value.length <= 8192) safe[key] = value
438
+ }
439
+ const cookies = nativeCookies(backend.headers['set-cookie'])
440
+ if (cookies.length) safe['set-cookie'] = cookies
441
+ res.writeHead(backend.statusCode || 502, safe)
442
+ backend.once('error', () => finish(unavailable()))
443
+ backend.once('aborted', () => finish(unavailable()))
444
+ backend.once('end', () => { res.end(() => finish()) })
445
+ backend.pipe(res)
446
+ })
447
+ owned.add(outgoing)
448
+ // No progress in either direction (a stalled backend, or a client that stopped reading and paused the socket).
449
+ outgoing.setTimeout(60000, () => finish(new HttpError(504, 'Backend request timed out')))
450
+ // The backend's own upload deadline is 300 s; nothing else needs a total limit after the headers.
451
+ if (req.method === 'POST') deadline = setTimeout(() => finish(new HttpError(504, 'Backend request timed out')), 310000)
452
+ outgoing.once('error', () => finish(unavailable()))
453
+ res.once('close', closed)
454
+ req.on('data', count)
455
+ req.pipe(outgoing)
456
+ } catch { finish(unavailable()) }
457
+ })
458
+ }
459
+ function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024, declared) {
265
460
  if (stopped || active >= 8) throw new HttpError(503, 'Backend request capacity is unavailable')
266
461
  if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
267
- const limit = url.pathname === '/_brain/uploads' && req.method === 'POST' ? 5 * 1024 * 1024 + 65536 : 65536
462
+ const post = req.method === 'POST'
463
+ const limit = url.pathname === '/_brain/uploads' && post ? 5 * 1024 * 1024 + 65536 : declared || post && /^\/api\/[^/]+\/batch$/.test(url.pathname) ? 1024 * 1024 : 65536
268
464
  if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
269
465
  active++
270
466
  return new Promise((resolve, reject) => {
271
467
  let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
272
468
  const chunks = []
273
- const headers = { 'x-request-id':rid }
274
- 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','user-agent','dnt','sec-gpc','cf-ipcountry']) {
275
- const value = req.headers[key]
276
- if (typeof value === 'string' && value.length <= 8192) headers[key] = value
277
- }
278
- const selected = []
279
- for (const part of String(req.headers.cookie || '').split(';')) {
280
- const name = part.trim().split('=')[0]
281
- if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
282
- }
283
- if (selected.length) headers.cookie = selected.join('; ')
469
+ const headers = requestHeaders(req, rid, declared?.headers)
284
470
  const finish = (error, backend) => {
285
471
  if (done) return
286
472
  done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
@@ -288,12 +474,16 @@ export function nativeAuthBridge(raw) {
288
474
  res.removeListener('close',cancel)
289
475
  if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
290
476
  const safe = { 'cache-control':'no-store' }
291
- for (const key of ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data']) {
477
+ for (const key of RESPONSE_HEADERS) {
292
478
  const value = backend.headers[key]
293
479
  if (typeof value === 'string' && value.length <= 8192) safe[key] = value
294
480
  }
295
- const cookies = (backend.headers['set-cookie'] || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192)
296
- if (cookies.length) safe['set-cookie'] = cookies.slice(0,8)
481
+ // the response headers a route contract allows its handler to set (docs/backend/routes.md)
482
+ if (declared) for (const [key, value] of Object.entries(backend.headers)) {
483
+ if ((key === 'link' || key === 'content-language' || key.startsWith('x-') && key !== 'x-request-id') && typeof value === 'string' && value.length <= 8192) safe[key] = value
484
+ }
485
+ const cookies = nativeCookies(backend.headers['set-cookie'])
486
+ if (cookies.length) safe['set-cookie'] = cookies
297
487
  try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
298
488
  }
299
489
  const cancel = () => { outgoing?.destroy(); finish(true) }
@@ -313,17 +503,17 @@ export function nativeAuthBridge(raw) {
313
503
  backend.once('end',()=>finish(false,backend)); backend.once('error',()=>finish(true)); backend.once('aborted',()=>finish(true))
314
504
  })
315
505
  owned.add(outgoing)
316
- 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)
506
+ timer = setTimeout(()=>{failure = new HttpError(504,'Backend request timed out');outgoing.destroy();finish(true)},declared || url.pathname === '/_brain/uploads' || url.pathname.startsWith('/_brain/uploads/') ? 35000 : 10000)
317
507
  outgoing.once('error',()=>finish(true)); req.once('aborted',cancel); res.once('close',cancel)
318
508
  req.on('data',data); req.once('end',end)
319
509
  if (req.readableEnded) end()
320
510
  } catch { outgoing?.destroy(); finish(true) }
321
511
  })
322
512
  }
323
- const resource = (incoming, response, rid, name) => {
513
+ const resource = (incoming, response, rid, name, serviceKey) => {
324
514
  if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
325
515
  const idPath = id => {
326
- 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')
516
+ 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(404, 'No record has this id; record ids are UUIDs')
327
517
  return '/api/' + name + '/' + id
328
518
  }
329
519
  const request = async (method, path, body, notFound) => {
@@ -337,11 +527,11 @@ export function nativeAuthBridge(raw) {
337
527
  }
338
528
  const req = Readable.from(bytes.length ? [bytes] : [])
339
529
  req.method = method
340
- req.headers = { cookie: cookies(incoming, false), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
530
+ req.$brainForwardedFor = forwardedFor(incoming)
531
+ // A service call acts as the key's owner, never as the visitor: it sends no session.
532
+ req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
341
533
  if (method !== 'GET') {
342
- let origin
343
- 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') }
344
- req.headers.origin = origin.origin
534
+ req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
345
535
  }
346
536
  let value
347
537
  try {
@@ -354,7 +544,14 @@ export function nativeAuthBridge(raw) {
354
544
  throw new HttpError(422, 'Invalid input', { errors })
355
545
  }
356
546
  if (status === 404 && notFound) { value = notFound(); return }
357
- if (status < 200 || status >= 300) throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed')
547
+ if (status < 200 || status >= 300) {
548
+ // the message stays generic; the backend's own code and fix (never driver text) let server code branch on it
549
+ let answer
550
+ try { answer = JSON.parse(data.toString('utf8'))?.error } catch {}
551
+ const code = typeof answer?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(answer.code) ? answer.code : undefined
552
+ const fix = code && typeof answer.fix === 'string' ? answer.fix.slice(0, 512) : undefined
553
+ throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed', { code, fix })
554
+ }
358
555
  if (status === 204) { value = null; return }
359
556
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
360
557
  try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
@@ -365,7 +562,8 @@ export function nativeAuthBridge(raw) {
365
562
  }
366
563
  const names = (value, label) => {
367
564
  const list = typeof value === 'string' ? value.split(',') : value
368
- if (!Array.isArray(list) || list.length > 16 || list.some(item => typeof item !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(item))) throw new HttpError(400, `Invalid native ${label} names`)
565
+ const shape = label === 'include' ? /^[a-z][a-z0-9_]{0,47}(?:\.[a-z][a-z0-9_]{0,47}){0,2}$/ : /^[a-z][a-z0-9_]{0,47}$/
566
+ if (!Array.isArray(list) || list.length > 16 || list.some(item => typeof item !== 'string' || !shape.test(item))) throw new HttpError(400, `Invalid native ${label} names`)
369
567
  return list.join(',')
370
568
  }
371
569
  return Object.freeze({
@@ -373,7 +571,7 @@ export function nativeAuthBridge(raw) {
373
571
  // filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
374
572
  list(options = {}) {
375
573
  if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !LIST_OPTIONS.includes(key))) throw new HttpError(400, 'Invalid native list options')
376
- const {search, limit, offset, filter, sort, cursor, after, include, aggregate, count} = options
574
+ const {search, limit, offset, filter, sort, cursor, after, include, aggregate, count, fields} = options
377
575
  if ((search !== undefined && (typeof search !== 'string' || Buffer.byteLength(search) > 256)) || (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) || (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000))) throw new HttpError(400, 'Invalid native list pagination or search')
378
576
  const q = new URLSearchParams()
379
577
  if (filter !== undefined) {
@@ -415,7 +613,11 @@ export function nativeAuthBridge(raw) {
415
613
  if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(value)) throw new HttpError(400, 'Invalid native list cursor')
416
614
  q.set(key, value)
417
615
  }
418
- for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count]]) if (value !== undefined) q.set(key, names(value, key))
616
+ for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count], ['fields', fields]]) if (value !== undefined) q.set(key, names(value, key))
617
+ if (options.owner !== undefined) {
618
+ if (options.owner !== 'me') throw new HttpError(400, "Invalid native list owner; use 'me'")
619
+ q.set('owner', 'me')
620
+ }
419
621
  const query = q.toString()
420
622
  return request('GET', '/api/' + name + (query ? '?' + query : ''))
421
623
  },
@@ -431,27 +633,30 @@ export function nativeAuthBridge(raw) {
431
633
  if (values && typeof values === 'object' && !Array.isArray(values) && !Object.keys(values).length) throw new HttpError(422, 'Send at least one field', { errors: { body: 'Send at least one field to change' } })
432
634
  return request('PATCH', idPath(id), values)
433
635
  },
636
+ // a declared atomic action or state transition (docs/backend/atomic-updates.md); the key still needs `<resource>:write`
637
+ action: (id, action, input = {}) => request('POST', idPath(id) + '/actions/' + encodeURIComponent(action), input),
638
+ transition: (id, transition, input = {}) => request('POST', idPath(id) + '/transitions/' + encodeURIComponent(transition), input),
434
639
  remove: id => request('DELETE', idPath(id)),
435
640
  // an earlier slug of a readable record → its current slug; live, unknown or unreadable slugs → null
436
641
  slugRedirect(slug) {
437
- if (typeof slug !== 'string' || !/^[A-Za-z0-9._~-]{1,512}$/.test(slug)) throw new HttpError(400, 'Invalid native slug')
642
+ if (typeof slug !== 'string' || !/^[A-Za-z0-9._~-]{1,512}$/.test(slug)) return Promise.resolve(null)
438
643
  return request('GET', '/api/' + name + '/redirects/' + slug, undefined, () => null).then(value => typeof value?.slug === 'string' ? value.slug : null)
439
644
  },
440
645
  })
441
646
  }
442
647
  async function login(incoming,response,rid,credentials) {
443
- if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
648
+ if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'B0742: Sign in through a same-origin POST server function')
444
649
  if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
445
650
  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')
446
- let origin
447
- 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')}
651
+ const origin=bridgeOrigin(incoming,rid,'Sign in through a same-origin POST server function')
448
652
  incoming.$nativeLoginStarted=true
449
653
  const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
450
- 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)}
654
+ const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
451
655
  let user
452
656
  try {
453
657
  await proxy(request,response,new URL('/_brain/auth/login',target),rid,(_res,status,data,headers)=>{
454
- 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')
658
+ if(status===403)throw new HttpError(403,"B0740: the backend refused this site's origin; fix: add the site origin to backend.origins in brain.json. The backend log has the seen and allowed origins. Run brain explain B0740")
659
+ 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===400||status===401||status===422?'Wrong email or password':'Sign-in is temporarily unavailable')
455
660
  let payload
456
661
  try {payload=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
457
662
  user=safeUser(payload?.user)
@@ -464,11 +669,88 @@ export function nativeAuthBridge(raw) {
464
669
  } finally {request.destroy();bytes.fill(0)}
465
670
  return {user}
466
671
  }
672
+ // Creates the account and leaves the session alone: `await ctx.backend.signup(v); return ctx.backend.login(v)` signs in
673
+ // too. A refused address is the email field's error: the same 422 for an invalid one and for one already taken, and no
674
+ // more than any signup form reveals by succeeding.
675
+ async function signup(incoming,response,rid,credentials) {
676
+ if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'B0742: Sign up through a same-origin POST server function')
677
+ 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')
678
+ if (Buffer.byteLength(credentials.password) < 12 || Buffer.byteLength(credentials.password) > 1024) throw new HttpError(422,'Use a password of 12 to 1024 bytes',{errors:{password:'Use a password of 12 to 1024 bytes'}})
679
+ const origin=bridgeOrigin(incoming,rid,'Sign up through a same-origin POST server function')
680
+ const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
681
+ const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
682
+ let user
683
+ try {
684
+ await proxy(request,response,new URL('/_brain/auth/signup',target),rid,(_res,status,data)=>{
685
+ if(status===403)throw new HttpError(403,"B0740: the backend refused this site's origin; fix: add the site origin to backend.origins in brain.json. Run brain explain B0740")
686
+ if(status===422)throw new HttpError(422,'This email cannot be used',{errors:{email:'This email cannot be used'}})
687
+ if(status===429)throw new HttpError(429,'Too many attempts. Wait before trying again.')
688
+ if(status!==200&&status!==201)throw unavailable()
689
+ try {user=safeUser(JSON.parse(data.toString('utf8'))?.user)}catch{throw unavailable()}
690
+ },16384)
691
+ } finally {request.destroy();bytes.fill(0)}
692
+ return {user}
693
+ }
694
+ // Google needs its nonce bound to the browser that asked for it, so both steps relay the HttpOnly nonce cookie. The page
695
+ // only ever sees the public client id and nonce it hands to Google.
696
+ const sessionCookie = /^(?:__Host-brain|__Host-brain-access|__Host-brain-refresh|__Host-brain-google)=/
697
+ const strictCookie = value => /(?:^|;)\s*HttpOnly(?:;|$)/i.test(value) && /(?:^|;)\s*Secure(?:;|$)/i.test(value) && /(?:^|;)\s*Path=\/(?:;|$)/i.test(value) && /(?:^|;)\s*SameSite=Lax(?:;|$)/i.test(value)
698
+ async function googleStep(incoming,response,rid,path,payload,cookie,read) {
699
+ if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
700
+ if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
701
+ let origin
702
+ 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')}
703
+ incoming.$nativeLoginStarted=true
704
+ const bytes=Buffer.from(JSON.stringify(payload))
705
+ 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),...(cookie?{cookie}:{})}
706
+ let result
707
+ try {
708
+ await proxy(request,response,new URL(path,target),rid,(_res,status,data,headers)=>{
709
+ if(status!==200)throw new HttpError(status===429?429:status<500?401:503,status===429?'Too many attempts. Wait before trying again.':status<500?'Google sign-in was not accepted':'Sign-in is temporarily unavailable')
710
+ let parsed
711
+ try {parsed=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
712
+ const selected=headers['set-cookie']||[]
713
+ if(new Set(selected.map(value=>value.split('=',1)[0])).size!==selected.length||!selected.length||selected.length>4||selected.some(value=>!sessionCookie.test(value)||!strictCookie(value)))throw unavailable()
714
+ result=read(parsed)
715
+ if(response.writableEnded||response.destroyed)throw unavailable()
716
+ incoming.$nativeLoginHeaders={'set-cookie':selected,'cache-control':'no-store'}
717
+ },16384)
718
+ } finally {request.destroy();bytes.fill(0)}
719
+ return result
720
+ }
721
+ const googleChallenge = (incoming,response,rid) => googleStep(incoming,response,rid,'/_brain/auth/google/challenge',{},null,payload=>{
722
+ if(typeof payload?.client_id!=='string'||typeof payload?.nonce!=='string'||!/^[a-f0-9]{64}$/.test(payload.nonce)||!Number.isInteger(payload?.expires_in))throw unavailable()
723
+ return {client_id:payload.client_id,nonce:payload.nonce,expires_in:payload.expires_in}
724
+ })
725
+ const googleLogin = (incoming,response,rid,values) => {
726
+ if(!values||typeof values!=='object'||Array.isArray(values)||Object.keys(values).some(key=>key!=='credential')||typeof values.credential!=='string'||!values.credential||Buffer.byteLength(values.credential)>16384)throw new HttpError(422,'Send the credential Google returned')
727
+ const nonce=String(incoming.headers.cookie||'').split(';').map(part=>part.trim()).find(part=>part.startsWith('__Host-brain-google='))?.slice('__Host-brain-google='.length)
728
+ if(!nonce||!/^[a-f0-9]{64}$/.test(nonce))throw new HttpError(401,'Google sign-in was not accepted')
729
+ return googleStep(incoming,response,rid,'/_brain/auth/google',{credential:values.credential},'__Host-brain-google='+nonce,payload=>{
730
+ const user=safeUser(payload?.user)
731
+ return {user}
732
+ })
733
+ }
467
734
  // ctx.backend for one server request (load, actions, RPC, dev sidecar): the same object everywhere
468
735
  const backend = (incoming, response, rid) => Object.freeze({
469
736
  resource: name => resource(incoming, response, rid, name),
737
+ // A server-only resource (`api: false`) from trusted server code, with BRAIN_SERVICE_API_KEY
738
+ service: name => {
739
+ if (!serviceKey) throw configuration(503, 'ctx.backend.service needs BRAIN_SERVICE_API_KEY: create an API key with a scope naming the resource')
740
+ return resource(incoming, response, rid, name, serviceKey)
741
+ },
742
+ // A declared file bucket from trusted server code, with BRAIN_SERVICE_API_KEY and its files.<bucket> scope
743
+ files: bucket => {
744
+ if (!serviceKey) throw configuration(503, 'ctx.backend.files needs BRAIN_SERVICE_API_KEY: create an API key with a files.<bucket> scope')
745
+ return bucketClient(target, serviceKey, bucket)
746
+ },
470
747
  slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
471
748
  login: credentials => login(incoming, response, rid, credentials),
749
+ signup: credentials => signup(incoming, response, rid, credentials),
750
+ google: Object.freeze({
751
+ challenge: () => googleChallenge(incoming, response, rid),
752
+ login: values => googleLogin(incoming, response, rid, values),
753
+ }),
472
754
  })
473
755
  return {
474
756
  proxy,
@@ -476,21 +758,23 @@ export function nativeAuthBridge(raw) {
476
758
  backend,
477
759
  login,
478
760
  async prepare(req, res, rid) {
479
- if (!cookies(req, false)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
480
- if (!req.$nativeAuth) req.$nativeAuth = call(req, res, '/_brain/auth/me', 'GET', rid).then(result => { req.$nativeUser = result.user })
761
+ if (!cookies(req, true)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
762
+ if (!req.$nativeAuth) req.$nativeAuth = (async () => {
763
+ req.$nativeUser = cookies(req, false) ? (await call(req, res, '/_brain/auth/me', 'GET', rid)).user : null
764
+ if (req.$nativeUser === null && /(?:^|;\s*)__Host-brain-refresh=/.test(req.headers.cookie || '')) await renew(req, res, rid)
765
+ })()
481
766
  await req.$nativeAuth
482
767
  },
483
768
  session(req, res, rid) {
484
769
  return {
485
770
  user: req.$nativeUser,
486
- set() { throw new HttpError(409, 'Use the native authentication API to establish a session') },
771
+ set() { throw configuration(409, 'With BRAIN_BACKEND_URL the backend owns the session: sign in with ctx.backend.login(credentials), or set BRAIN_BACKEND_SESSION=app so this app keeps its own signed session, and keep a server-only value with ctx.session.setSecret(value)', rid, 'Sign-in is not available right now') },
487
772
  clear() {
488
773
  if (req.$nativeLoginStarted) throw new HttpError(409,'Use a separate server request to sign out after signing in')
489
- if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'Sign out through a same-origin POST request')
490
- let origin
491
- 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') }
774
+ if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'B0742: Sign out through a same-origin POST request')
775
+ const origin = bridgeOrigin(req, rid, 'Sign out through a same-origin POST request')
492
776
  if (!req.$nativeLogout) {
493
- 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 })
777
+ req.$nativeLogout = call(req, res, '/_brain/auth/logout', 'POST', rid, origin).then(headers => { req.$nativeLogoutDone = true; req.$nativeLogoutHeaders = headers }, error => { req.$nativeLogoutDone = true; req.$nativeLogoutError = true; throw error })
494
778
  req.$nativeLogout.catch(() => {})
495
779
  }
496
780
  return req.$nativeLogout
@@ -498,15 +782,23 @@ export function nativeAuthBridge(raw) {
498
782
  headers: () => req.$nativeLoginHeaders || {},
499
783
  }
500
784
  },
785
+ fileStream,
501
786
  _socketCookie: req => cookies(req, false),
502
- close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy() },
787
+ _forwardedFor: forwardedFor,
788
+ close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy(); streamAgent.destroy() },
503
789
  }
504
790
  }
505
791
 
506
792
  export function createServer(o = {}) {
507
793
  const dist = resolve(o.dist || process.cwd())
508
- const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
509
- const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL)
794
+ // ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
795
+ const { BRAIN_SERVICE_API_KEY: serviceKey, BRAIN_JOBS_API_KEY: _jobs, BRAIN_JOBS_SYSTEM_KEY: _jobsSystem, BRAIN_ROUTES_API_KEY: _routes, BRAIN_ROUTES_SYSTEM_KEY: _routesSystem, ...visibleEnv } = o.env || ENV
796
+ const env = Object.freeze(visibleEnv)
797
+ const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry))
798
+ // Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
799
+ const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
800
+ if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
801
+ const nativeSession = native && backendSession !== 'app'
510
802
  if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
511
803
  const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
512
804
  const secret = o.secret ?? env.BRAIN_SECRET ?? ''
@@ -532,6 +824,20 @@ export function createServer(o = {}) {
532
824
  if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
533
825
  sink(level, msg, safe)
534
826
  }
827
+ // An origin refusal (B0741, B0742): the answer stays minimal and the operator's log gets the reason. It is bounded per
828
+ // minute because anyone can trigger it, and it never names an allowlist: this server only compares against its own host.
829
+ let originWindow = 0, originLogs = 0
830
+ const originRefused = (code, req, rid, kind) => {
831
+ const minute = Math.floor(Date.now() / 60000)
832
+ if (originWindow !== minute) { originWindow = minute; originLogs = 0 }
833
+ if (originLogs++ >= 16) return
834
+ const bound = v => typeof v === 'string' ? v.slice(0, 256) : null
835
+ const seenOrigin = bound(req.headers.origin), host = bound(req.headers.host), forwardedHost = bound(req.headers['x-forwarded-host'])
836
+ const fix = seenOrigin
837
+ ? `Origin ${seenOrigin} does not match the host this server sees (${forwardedHost || host}). Call it from the site itself; behind a proxy forward the host (nginx: proxy_set_header Host $host;). There is no allowlist to widen. Run brain explain ${code}`
838
+ : `The request has no Origin${code === 'B0741' ? ' and no Sec-Fetch-Site' : ''}, so its site cannot be proven. Send it from the page's own origin in a browser; a script sets Origin to the site origin. Run brain explain ${code}`
839
+ log('error', 'origin refused', { code, kind, rid, seenOrigin, secFetchSite: bound(req.headers['sec-fetch-site']), host, forwardedHost, fix })
840
+ }
535
841
  // log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
536
842
  const trace = e => {
537
843
  const out = { stack: String((e && e.stack) || e) }
@@ -560,6 +866,22 @@ export function createServer(o = {}) {
560
866
  if (nativeSockets && !native) throw Error('Native sockets require a configured native backend')
561
867
  // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
562
868
  const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
869
+ // The address RPC and form limits key on, ctx.req.ip and the logs: a client header counts only through a trusted proxy.
870
+ const proxies = trustedProxies(o.trustProxy ?? (env.BRAIN_TRUST_PROXY ? env.BRAIN_TRUST_PROXY.split(',').map(entry => entry.trim()) : mf.trustProxy) ?? [])
871
+ let proxyWarned = false
872
+ const ipOf = req => {
873
+ const forwarded = req.headers['x-forwarded-for']
874
+ if (forwarded && !proxies && !proxyWarned && /^(?:127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || '')) {
875
+ proxyWarned = true
876
+ log('warn', 'trust-proxy-not-configured', { fix: 'a local proxy is forwarding client addresses and they are ignored, so all clients share one rate-limit key; set trustProxy in server/platform.jsbrain or BRAIN_TRUST_PROXY=127.0.0.1,::1' })
877
+ }
878
+ return resolveClient(req.socket.remoteAddress, forwarded, proxies)
879
+ }
880
+ // server/routes/* of the native backend, listed by the build: only these exact shapes cross the bridge
881
+ const nativeRoutes = (Array.isArray(mf.nativeRoutes) ? mf.nativeRoutes : []).map(route => {
882
+ if (typeof route?.path !== 'string' || !Array.isArray(route.headers)) throw Error('Manifest nativeRoutes must list path and headers')
883
+ return { headers: route.headers, rx: new RegExp('^' + route.path.split('/').map(part => /^\{.+\}$/.test(part) ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('/') + '$') }
884
+ })
563
885
  // the real path: Node imports file URLs through realpath, and the /_b/ hook must name the same modules (a symlinked
564
886
  // dist such as /var → /private/var on macOS would otherwise load the core twice)
565
887
  const distURL = pathToFileURL(real(dist) + sep).href
@@ -675,6 +997,13 @@ export function createServer(o = {}) {
675
997
  const path = typeof custom === 'string' && file(custom)
676
998
  return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
677
999
  }
1000
+ // `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
1001
+ const reservedStatus = pathname => {
1002
+ const m = /^\/(403|404|410|5\d\d)(?:\.html)?$/.exec(pathname)
1003
+ if (!m) return 0
1004
+ const n = +m[1]
1005
+ return file(`${n}.html`) || (mf.errors && mf.errors[n]) || (n === 403 && mf.forbidden) || (n === 404 && mf.notFound) ? n : 0
1006
+ }
678
1007
  // a file inside dist/ (never outside: no `..`, no absolute paths)
679
1008
  function file(rel) {
680
1009
  const p = normalize(join(dist, rel))
@@ -695,13 +1024,13 @@ export function createServer(o = {}) {
695
1024
 
696
1025
  const context = (req, res, url, extra) => {
697
1026
  const cookies = parseCookies(req.headers.cookie)
698
- const { user, session: ses } = native ? { user: req.$nativeUser ?? null, session: native.session(req, res, res.getHeader('x-request-id')) } : sessionFrom(cookies, secret)
1027
+ const { user, session: ses } = nativeSession ? { user: req.$nativeUser ?? null, session: sessionSecrets(native.session(req, res, res.getHeader('x-request-id')), cookies, secret) } : sessionFrom(cookies, secret)
699
1028
  return {
700
1029
  url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
701
1030
  ...(native ? { backend: native.backend(req, res, res.getHeader('x-request-id')) } : {}),
702
1031
  // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
703
1032
  prefetch: req.headers['x-brain-prefetch'] === '1',
704
- req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
1033
+ req: { method: req.method, headers: req.headers, ip: ipOf(req) }, signal: lifetime(res), ...extra,
705
1034
  }
706
1035
  }
707
1036
 
@@ -767,7 +1096,7 @@ export function createServer(o = {}) {
767
1096
  const ctx = context(req, res, url, { params })
768
1097
  // a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
769
1098
  // user's page from the HTTP cache or bfcache. Anonymous pages keep `no-cache` + ETag.
770
- const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
1099
+ const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || ctx.cookies[SECRET_COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
771
1100
  const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
772
1101
  let props = fixed || {}, extra = {}
773
1102
  if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
@@ -776,19 +1105,19 @@ export function createServer(o = {}) {
776
1105
  const guards = [], middleware = []
777
1106
  for (const g of route.guards || []) guards.push(await load(g))
778
1107
  for (const m of route.middleware || []) middleware.push(await load(m))
779
- r = await runChain({ guards, middleware, ctx })
1108
+ r = await pageLocale.run(route.locale, () => runChain({ guards, middleware, ctx }))
780
1109
  } catch (e) {
781
1110
  log('error', 'guard failed', { rid, path: url.pathname, ...trace(e) })
782
1111
  return errorPage(req, res, route, params, url, rid)
783
1112
  }
784
1113
  // a guarded page must not reach the index through its login redirect (§33): the redirect itself says noindex
785
- if (r.redirect) return send(res, r.status, '', { location: withBase(redirect(r.redirect, r.status ?? 303).redirect), 'cache-control': 'no-store', 'x-robots-tag': NOINDEX, ...ctx.session.headers() })
1114
+ if (r.redirect) return send(res, r.status, '', { location: withBase(linkTo(route, redirect(r.redirect, r.status ?? 303).redirect)), 'cache-control': 'no-store', 'x-robots-tag': NOINDEX, ...ctx.session.headers() })
786
1115
  if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
787
1116
  extra = r.headers
788
1117
  }
789
1118
  // what load() thrown or returned instead of props answers: redirect, notFound() (F11), deliberate status, failure
790
1119
  const loadOutcome = e => {
791
- if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
1120
+ if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(linkTo(route, redirect(e.redirect, e.status ?? 303).redirect)), ...ctx.session.headers() })
792
1121
  if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return missing(req, res, e) // `notFound()` 404 / `gone()` 410
793
1122
  const status = intendedStatus(e) || 500
794
1123
  log('error', 'load failed', { rid, path: url.pathname, status, ...trace(e) })
@@ -798,7 +1127,8 @@ export function createServer(o = {}) {
798
1127
  try {
799
1128
  if (!fixed && route.server) {
800
1129
  const m = await load(route.server)
801
- if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
1130
+ if (route.i18n) await load(route.i18n) // t() inside load(): the dictionary is there before it runs
1131
+ if (typeof m.load === 'function') props = (await withTimeout(pageLocale.run(route.locale, () => Promise.resolve(m.load(ctx))), TIMEOUT, ctx)) ?? {}
802
1132
  }
803
1133
  } catch (e) {
804
1134
  return loadOutcome(e)
@@ -833,7 +1163,8 @@ export function createServer(o = {}) {
833
1163
  async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
834
1164
  const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
835
1165
  const sm = await load(route.server)
836
- const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
1166
+ if (route.i18n) await load(route.i18n)
1167
+ const loadP = typeof sm.load === 'function' ? withTimeout(pageLocale.run(route.locale, () => Promise.resolve().then(() => sm.load(ctx))), TIMEOUT, ctx) : Promise.resolve(null)
837
1168
  loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
838
1169
  let timer
839
1170
  const early = await Promise.race([loadP.then(v => ({ v }), e => ({ e })), new Promise(r => (timer = setTimeout(r, STREAM_HOLD)))])
@@ -892,7 +1223,7 @@ export function createServer(o = {}) {
892
1223
  if (props.redirect || props.notFound === true) throw props
893
1224
  } catch (e) {
894
1225
  // too late for a real redirect: the page says noindex so the stub never enters the index
895
- if (e && e.redirect) return end(`<meta name="robots" content="noindex"><script>location.replace(${jsonInHtml(withBase(redirect(e.redirect, e.status ?? 303).redirect))})</script>`)
1226
+ if (e && e.redirect) return end(`<meta name="robots" content="noindex"><script>location.replace(${jsonInHtml(withBase(linkTo(route, redirect(e.redirect, e.status ?? 303).redirect)))})</script>`)
896
1227
  // a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
897
1228
  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, ''))
898
1229
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
@@ -966,7 +1297,7 @@ export function createServer(o = {}) {
966
1297
  await new Promise(r => setTimeout(r, 0))
967
1298
  globalThis.__brainSSR = null
968
1299
  fillValues(root)
969
- const body = root.innerHTML
1300
+ const body = localizeLinks(route, root.innerHTML)
970
1301
  stop()
971
1302
  // SEO head: the codegen emits `export const seo` / `head` in the view's server module; the client chunk's V.seo
972
1303
  // (older builds) is the fallback. Still inside the page's locale: `<meta seo title={t('…')}>` is translated.
@@ -987,7 +1318,7 @@ export function createServer(o = {}) {
987
1318
  const fail = (status, error, { $h, ...extra } = {}) => send(res, status, json({ error, ...extra }), { 'content-type': MIME['.json'], 'cache-control': 'no-store', ...$h })
988
1319
  if (req.method !== 'POST') return fail(405, 'Use POST', { $h: { allow: 'POST' } })
989
1320
  if (req.headers['x-brain-rpc'] !== '1') return fail(403, 'Missing X-Brain-RPC header')
990
- if (!sameOrigin(req)) return fail(403, 'Cross-origin request')
1321
+ if (!sameOrigin(req)) return originRefused('B0741', req, rid, 'rpc'), fail(403, 'Cross-origin request', { brainCode: 'B0741' })
991
1322
  const modPath = mf.rpc[hash]
992
1323
  if (!modPath || !/^[A-Za-z_$][\w$]*$/.test(fn)) return fail(404, 'Unknown function')
993
1324
  const ra = overLimit(ipOf(req) + ' ' + hash + '/' + fn, LIMIT) // before reading the body
@@ -1030,7 +1361,7 @@ export function createServer(o = {}) {
1030
1361
  // ---------- form posted without JS (§38; transport, the logic is callForm, shared with the dev server) ----------
1031
1362
  async function formPost(req, res, route, params, url, rid) {
1032
1363
  const plain = (status, text, h = {}) => send(res, status, text, { 'content-type': MIME['.txt'], 'cache-control': 'no-store', ...h })
1033
- if (!sameOrigin(req, true)) return plain(403, 'Cross-origin form post')
1364
+ if (!sameOrigin(req, true)) return originRefused('B0741', req, rid, 'form'), plain(403, 'Cross-origin form post', { 'x-brain-code': 'B0741' })
1034
1365
  if (!/^application\/x-www-form-urlencoded/i.test(req.headers['content-type'] || '')) return plain(415, 'Forms are sent as application/x-www-form-urlencoded')
1035
1366
  let raw
1036
1367
  try {
@@ -1217,6 +1548,16 @@ export function createServer(o = {}) {
1217
1548
 
1218
1549
  // ---------- dispatch ----------
1219
1550
  const server = http(async (req, res) => {
1551
+ // Only a file upload may outlast the 30-second body budget; the dispatcher lifts this guard for that path alone.
1552
+ if (native) {
1553
+ req.$brainBodyGuard = setTimeout(() => {
1554
+ if (req.complete) return
1555
+ if (!res.headersSent) res.writeHead(408, { connection:'close' })
1556
+ res.end(); req.socket?.destroy()
1557
+ }, 30000)
1558
+ req.$brainBodyGuard.unref?.()
1559
+ res.once('close', () => clearTimeout(req.$brainBodyGuard))
1560
+ }
1220
1561
  const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
1221
1562
  res.setHeader('x-request-id', rid)
1222
1563
  const t0 = performance.now()
@@ -1225,6 +1566,8 @@ export function createServer(o = {}) {
1225
1566
  if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
1226
1567
  })
1227
1568
  try {
1569
+ // `//` makes new URL throw and `//host/x` would be read as another host
1570
+ if (req.url.startsWith('//')) return notFound(req, res)
1228
1571
  const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
1229
1572
  // /_health stays at the root: the supervisor calls it there whatever the base
1230
1573
  if (full.pathname === '/_health' || full.pathname === BASE + '_health') return send(res, 200, json({ ok: true, build: mf.build || null }), { 'content-type': MIME['.json'], 'cache-control': 'no-store' })
@@ -1235,7 +1578,10 @@ export function createServer(o = {}) {
1235
1578
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
1236
1579
  }
1237
1580
  req.$brainPath = url.pathname
1581
+ if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
1238
1582
  if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
1583
+ const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
1584
+ if (declaredRoute) return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
1239
1585
  // redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
1240
1586
  // same-site targets get the base, the query is kept when the target has none; slash policy and host in the same hop
1241
1587
  const to = (req.method === 'GET' || req.method === 'HEAD') && canonicalOf(req, url)
@@ -1246,6 +1592,10 @@ export function createServer(o = {}) {
1246
1592
  if (url.pathname.startsWith('/_server/')) return notFound(req, res) // server code is never served
1247
1593
  const post = req.method === 'POST'
1248
1594
  if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
1595
+ // a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
1596
+ const reserved = post ? 0 : reservedStatus(url.pathname)
1597
+ if (reserved === 403) return forbidden(req, res, url, rid, '')
1598
+ if (reserved) return failPage(res, reserved, rid)
1249
1599
  for (const route of routes) {
1250
1600
  const m = route.rx.exec(url.pathname)
1251
1601
  if (!m) continue
@@ -1257,6 +1607,8 @@ export function createServer(o = {}) {
1257
1607
  if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
1258
1608
  if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return
1259
1609
  if (serveStatic(req, res, url)) return
1610
+ // browsers still ask for /favicon.ico: the app's SVG icon answers it
1611
+ if (url.pathname === '/favicon.ico' && file('favicon.svg')) return send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
1260
1612
  notFound(req, res)
1261
1613
  } catch (e) {
1262
1614
  log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
@@ -1265,29 +1617,114 @@ export function createServer(o = {}) {
1265
1617
  }
1266
1618
  })
1267
1619
  if (native) server.once('close', () => native.close())
1268
- if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,log})
1620
+ if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,client:native._forwardedFor,log})
1269
1621
  server.headersTimeout = 15000
1270
- server.requestTimeout = 30000
1622
+ // With a native backend the file upload deadline is enforced per request (above); 30 s stays for every other body.
1623
+ server.requestTimeout = native ? 320000 : 30000
1271
1624
  return server
1272
1625
  }
1273
1626
 
1274
1627
  // ---------- session (signed cookie; shared with the dev server) ----------
1275
- const COOKIE = '__Host-session'
1276
- // The user of a request: the signed `__Host-session` cookie (HMAC-SHA256 with BRAIN_SECRET), plus the session handle
1277
- // to set/clear it. Same in `node server.mjs` and in `brain run` (the dev sidecar imports it).
1628
+ const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
1629
+ // Keys that still verify and decrypt during a rotation; new cookies always use the current key.
1630
+ const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
1631
+ const sessionVersion = () => process.env.BRAIN_SESSION_VERSION || ''
1632
+ const sessionSeconds = () => { const n = Number(process.env.BRAIN_SESSION_SECONDS); return Number.isInteger(n) && n > 0 ? n : DEFAULT_SESSION_SECONDS }
1633
+ // Cookies of this process signed out before their expiry (mac -> exp). One process only: a shared store does not exist
1634
+ // here, so a deployment with several processes signs everybody out with BRAIN_SESSION_VERSION instead.
1635
+ const REVOKED = new Map(), REVOKED_MAX = 10000
1636
+ const revoke = (mac, exp) => {
1637
+ const now = Date.now()
1638
+ for (const [k, e] of REVOKED) if (e <= now) REVOKED.delete(k)
1639
+ while (REVOKED.size >= REVOKED_MAX) REVOKED.delete(REVOKED.keys().next().value)
1640
+ REVOKED.set(mac, exp)
1641
+ }
1642
+ const secretKey = secret => Buffer.from(hkdfSync('sha256', secret, '', 'brain session secret v1', 32))
1643
+ const seal = (secret, text) => {
1644
+ const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', secretKey(secret), iv)
1645
+ cipher.setAAD(Buffer.from(SECRET_COOKIE))
1646
+ const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
1647
+ return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64url')
1648
+ }
1649
+ const unseal = (secret, value) => {
1650
+ try {
1651
+ const raw = Buffer.from(value, 'base64url')
1652
+ if (raw.length < 29) return null
1653
+ const decipher = createDecipheriv('aes-256-gcm', secretKey(secret), raw.subarray(0, 12))
1654
+ decipher.setAAD(Buffer.from(SECRET_COOKIE))
1655
+ decipher.setAuthTag(raw.subarray(12, 28))
1656
+ return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
1657
+ } catch { return null }
1658
+ }
1659
+ // `ctx.session.setSecret(value, { maxAge })`, `.secret()` and `.clearSecret()` on any session handle (signed cookie or
1660
+ // native bridge): server-only values such as a provider token, AES-256-GCM encrypted in their own cookie. They never
1661
+ // fill `ctx.user`, and `session.clear()` removes them too.
1662
+ const missingSecret = what => new Error(`B0782: BRAIN_SECRET is not set: ${what} need it; fix: set 32+ random bytes in the host environment or in .brain_env (openssl rand -hex 32)`)
1663
+ export function sessionSecrets(session, cookies, secret) {
1664
+ const out = []
1665
+ const write = (value, maxAge) => { out.length = 0; out.push(`${SECRET_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${Math.max(0, maxAge)}`) }
1666
+ let current
1667
+ const read = () => {
1668
+ if (current !== undefined) return current
1669
+ current = null
1670
+ const v = cookies && cookies[SECRET_COOKIE]
1671
+ if (!v || !secret) return current
1672
+ for (const key of [secret, ...previousKeys()]) {
1673
+ const text = unseal(key, v)
1674
+ if (text === null) continue
1675
+ try {
1676
+ const s = JSON.parse(text)
1677
+ if (s && s.exp > Date.now()) current = s.v
1678
+ } catch {}
1679
+ break
1680
+ }
1681
+ return current
1682
+ }
1683
+ const { clear, headers } = session
1684
+ const clearSecret = () => { write('', 0); current = null }
1685
+ return Object.assign(session, {
1686
+ secret: read,
1687
+ setSecret(value, { maxAge = sessionSeconds() } = {}) {
1688
+ if (!secret) throw missingSecret('session secrets')
1689
+ const text = JSON.stringify({ v: value, exp: Date.now() + maxAge * 1000 })
1690
+ if (value === undefined || text === undefined || JSON.parse(text).v === undefined) throw new Error('session.setSecret needs a JSON value')
1691
+ const sealed = seal(secret, text)
1692
+ if (sealed.length > SECRET_MAX) throw new Error(`session secret too large (${sealed.length} bytes, limit ${SECRET_MAX}): keep it server-side and store a reference`)
1693
+ write(sealed, maxAge)
1694
+ current = value
1695
+ },
1696
+ clearSecret,
1697
+ clear(...args) { const result = clear.apply(session, args); if (out.length || (cookies && cookies[SECRET_COOKIE] !== undefined)) clearSecret(); return result },
1698
+ headers() {
1699
+ const base = headers.call(session)
1700
+ if (!out.length) return base
1701
+ const own = base['set-cookie']
1702
+ return { ...base, 'set-cookie': [...(Array.isArray(own) ? own : own ? [own] : []), ...out] }
1703
+ },
1704
+ })
1705
+ }
1706
+ // The user of a request: the signed `__Host-session` cookie (HMAC-SHA256 with BRAIN_SECRET; BRAIN_SECRET_PREVIOUS still
1707
+ // verifies during a rotation, BRAIN_SESSION_VERSION signs everybody out), plus the session handle to set/clear it. Same
1708
+ // in `node server.mjs` and in `brain run` (the dev sidecar imports it).
1278
1709
  export function sessionFrom(cookies, secret) {
1279
- const sign = data => b64u(createHmac('sha256', secret).update(data).digest())
1280
- let cur = null
1710
+ const signWith = (key, data) => b64u(createHmac('sha256', key).update(data).digest())
1711
+ const sign = data => signWith(secret, data)
1712
+ let cur = null, curMac = null
1281
1713
  const v = cookies && cookies[COOKIE]
1282
1714
  if (v && secret) {
1283
1715
  const i = v.lastIndexOf('.')
1284
1716
  if (i > 0) {
1285
- const data = v.slice(0, i), mac = Buffer.from(v.slice(i + 1)), good = Buffer.from(sign(data))
1286
- if (mac.length === good.length && timingSafeEqual(mac, good))
1287
- try {
1288
- const s = JSON.parse(Buffer.from(data, 'base64url').toString())
1289
- if (s && s.exp > Date.now()) cur = s
1290
- } catch {}
1717
+ const data = v.slice(0, i), mac = Buffer.from(v.slice(i + 1))
1718
+ for (const key of [secret, ...previousKeys()]) {
1719
+ const good = Buffer.from(signWith(key, data))
1720
+ if (mac.length === good.length && timingSafeEqual(mac, good)) {
1721
+ try {
1722
+ const s = JSON.parse(Buffer.from(data, 'base64url').toString())
1723
+ if (s && s.exp > Date.now() && (s.sv ?? '') === sessionVersion() && !REVOKED.has(v.slice(i + 1))) { cur = s; curMac = v.slice(i + 1) }
1724
+ } catch {}
1725
+ break
1726
+ }
1727
+ }
1291
1728
  }
1292
1729
  }
1293
1730
  const set = []
@@ -1296,14 +1733,16 @@ export function sessionFrom(cookies, secret) {
1296
1733
  const user = cur ? cur.u : null
1297
1734
  return {
1298
1735
  user,
1299
- session: {
1736
+ session: sessionSecrets({
1300
1737
  user,
1301
- set(u, { maxAge = 7 * 24 * 3600 } = {}) {
1302
- if (!secret) throw new Error('BRAIN_SECRET is not set: sessions need it (32+ random bytes in .brain_env)')
1303
- const data = b64u(JSON.stringify({ u, exp: Date.now() + maxAge * 1000 }))
1738
+ set(u, { maxAge = sessionSeconds() } = {}) {
1739
+ if (!secret) throw missingSecret('sessions')
1740
+ const version = sessionVersion()
1741
+ const data = b64u(JSON.stringify({ u, exp: Date.now() + maxAge * 1000, ...(version ? { sv: version } : {}) }))
1304
1742
  set.push(cookie(`${data}.${sign(data)}`, maxAge))
1305
1743
  },
1306
1744
  clear() {
1745
+ if (curMac) revoke(curMac, cur.exp)
1307
1746
  set.push(cookie('', 0))
1308
1747
  cleared = true
1309
1748
  },
@@ -1312,7 +1751,7 @@ export function sessionFrom(cookies, secret) {
1312
1751
  // response, so the logout call took ~18 s (F16). Nothing private is in that cache: RPC is no-store and SSR
1313
1752
  // HTML rendered with a session cookie is `private, no-store` (ssr() → keep).
1314
1753
  headers: () => ({ ...(set.length ? { 'set-cookie': set } : {}), ...(cleared ? { 'clear-site-data': '"storage"' } : {}) }),
1315
- },
1754
+ }, cookies, secret),
1316
1755
  }
1317
1756
  }
1318
1757
 
@@ -1378,9 +1817,30 @@ export function overLimit(key, max = 60) {
1378
1817
  if (!h || now - h[0] > 60000) HITS.set(key, (h = [now, 0]))
1379
1818
  return ++h[1] > max ? Math.ceil((h[0] + 60000 - now) / 1000) : 0
1380
1819
  }
1381
- const withTimeout = (p, ms) => {
1820
+ // t(), fmt and locale() read globalThis.__brainLocale. A render sets it while nothing else runs (renders are serialized);
1821
+ // load(), guards and form handlers overlap across awaits, so each carries its page's language in the async context.
1822
+ const pageLocale = new AsyncLocalStorage()
1823
+ let renderLocale = globalThis.__brainLocale
1824
+ Object.defineProperty(globalThis, '__brainLocale', { configurable: true, get: () => pageLocale.getStore() ?? renderLocale, set: v => (renderLocale = v) })
1825
+ // ctx.signal: aborted when the client disconnects before the answer ends or the function passes its deadline, so the
1826
+ // work it started (fetch, a query) stops with it.
1827
+ const aborts = new WeakMap()
1828
+ const newSignal = () => {
1829
+ const c = new AbortController()
1830
+ aborts.set(c.signal, c)
1831
+ return c.signal
1832
+ }
1833
+ const lifetime = res => {
1834
+ const signal = newSignal()
1835
+ res.once?.('close', () => res.writableFinished || aborts.get(signal).abort()) // a captured render has no socket
1836
+ return signal
1837
+ }
1838
+ const withTimeout = (p, ms, ctx) => {
1382
1839
  let t
1383
- return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => rej(new HttpError(504, 'timeout')), ms)))]).finally(() => clearTimeout(t))
1840
+ return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => {
1841
+ aborts.get(ctx?.signal)?.abort()
1842
+ rej(new HttpError(504, 'timeout'))
1843
+ }, ms)))]).finally(() => clearTimeout(t))
1384
1844
  }
1385
1845
  // RPC body → arguments: a JSON array, with __proto__/constructor/prototype keys dropped. Throws HttpError(400).
1386
1846
  export function parseArgs(raw) {
@@ -1408,7 +1868,8 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
1408
1868
  return { status: e.status || 400, body: { error: e.message } }
1409
1869
  }
1410
1870
  try {
1411
- const v = await withTimeout(Promise.resolve(fn(ctx, ...a)), timeout)
1871
+ ctx.signal ||= newSignal() // the dev sidecar builds its own ctx
1872
+ const v = await withTimeout(Promise.resolve(fn(ctx, ...a)), timeout, ctx)
1412
1873
  return { status: 200, body: v ?? null }
1413
1874
  } catch (e) {
1414
1875
  if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
@@ -1449,7 +1910,8 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
1449
1910
  if (Object.keys(errors).length) return again(errors, '')
1450
1911
  let r
1451
1912
  try {
1452
- r = await withTimeout(Promise.resolve(module[fn](ctx, values)), timeout)
1913
+ ctx.signal ||= newSignal()
1914
+ r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(module[fn](ctx, values))), timeout, ctx)
1453
1915
  } catch (e) {
1454
1916
  if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
1455
1917
  if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
@@ -1509,7 +1971,39 @@ function parseCookies(h = '') {
1509
1971
  }
1510
1972
  return out
1511
1973
  }
1512
- const ipOf = req => String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || '?'
1974
+ const plainIp = text => { const ip = String(text).trim().replace(/^"|"$/g, '').replace(/^\[([^\]]+)\](?::\d+)?$/, '$1').replace(/^(\d+\.\d+\.\d+\.\d+):\d+$/, '$1').replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1'); return isIP(ip) ? ip : null }
1975
+ /** platform.trustProxy: one BlockList of proxies. Same limits as the backend's parse_proxy (brain-backend/src/platform.rs). */
1976
+ export function trustedProxies(list = []) {
1977
+ const nets = new BlockList()
1978
+ for (const entry of list) {
1979
+ const [address, prefix, extra] = String(entry).split('/')
1980
+ const family = isIP(address)
1981
+ const bits = prefix === undefined ? (family === 4 ? 32 : 128) : Number(prefix)
1982
+ const wide = family === 4 ? bits < 8 : bits < 16 && !(bits === 7 && /^fc00::$/i.test(address))
1983
+ if (!family || extra !== undefined || !Number.isInteger(bits) || bits > (family === 4 ? 32 : 128)) throw Error(`trustProxy entry \`${entry}\` must be an IP address or CIDR range`)
1984
+ if (wide) throw Error(`trustProxy entry \`${entry}\` is too wide; use at least /8 for IPv4 and /16 for IPv6, and list your proxy ranges`)
1985
+ nets.addSubnet(address, bits, family === 4 ? 'ipv4' : 'ipv6')
1986
+ }
1987
+ return list.length ? nets : null
1988
+ }
1989
+ /**
1990
+ * The client is the right-most forwarded hop that is not a trusted proxy, and only when the TCP peer is trusted: hops
1991
+ * left of it are client-supplied and never consulted. Same walk as resolve_client (brain-backend/src/edge_http.rs).
1992
+ */
1993
+ export function resolveClient(peer, forwarded, nets) {
1994
+ const socketPeer = plainIp(peer ?? '')
1995
+ const trusted = ip => nets.check(ip, isIP(ip) === 4 ? 'ipv4' : 'ipv6')
1996
+ if (!socketPeer) return '?'
1997
+ if (!nets || !trusted(socketPeer)) return socketPeer
1998
+ let client = socketPeer
1999
+ for (const text of String(forwarded ?? '').split(',').reverse()) {
2000
+ const ip = plainIp(text)
2001
+ if (!ip) return client
2002
+ client = ip
2003
+ if (!trusted(ip)) return ip
2004
+ }
2005
+ return client
2006
+ }
1513
2007
  function body(req, max) {
1514
2008
  return new Promise((ok, fail) => {
1515
2009
  let n = 0
@@ -1568,7 +2062,7 @@ export function prefersMarkdown(accept) {
1568
2062
  return md > 0 && (md > html || (md === html && mdAt < htmlAt))
1569
2063
  }
1570
2064
  // SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
1571
- // F5: the build writes an SSR route's canonical/og:url from its pattern (/jobs/:slug); the request has the real path.
2065
+ // F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
1572
2066
  // A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
1573
2067
  function head(page, seo, path) {
1574
2068
  let h = page
@@ -1637,6 +2131,7 @@ function hook(base, app = '/') {
1637
2131
  )
1638
2132
  }
1639
2133
 
2134
+ const LISTEN_WAIT_MS = 10000
1640
2135
  // `node server.mjs` (directly or under `brain start`); paths compared after symlinks (/var → /private/var on macOS)
1641
2136
  function real(p) {
1642
2137
  try {
@@ -1650,10 +2145,22 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
1650
2145
  const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
1651
2146
  const fd = ENV.BRAIN_LISTEN_FD
1652
2147
  const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
1653
- s.listen(where, () => {
2148
+ s.once('listening', () => {
1654
2149
  process.stdout.write(`brain:ready ${process.pid}\n`) // the supervisor waits for this line (§34)
1655
2150
  console.log(json({ t: new Date().toISOString(), level: 'info', msg: 'listening', ...(fd ? { fd: Number(fd) } : where) }))
1656
2151
  })
2152
+ // A restart may find the previous instance still draining: wait for it, briefly. Two live servers never share a port.
2153
+ const waitUntil = Date.now() + LISTEN_WAIT_MS
2154
+ s.on('error', error => {
2155
+ if (error.code === 'EADDRINUSE' && !fd && !s.listening && Date.now() < waitUntil) return void setTimeout(() => s.listen(where), 100)
2156
+ if (s.listening) throw error
2157
+ const port = where.port
2158
+ const fix = error.code === 'EADDRINUSE' ? `find the owner with \`lsof -nP -iTCP:${port} -sTCP:LISTEN\` (Linux: \`ss -ltnp 'sport = :${port}'\`), stop it, or set another PORT` : 'use another HOST or PORT'
2159
+ const cause = error.code === 'EADDRINUSE' ? `another live process still holds the port after ${LISTEN_WAIT_MS / 1000} s` : error.code
2160
+ console.error(json({ t: new Date().toISOString(), level: 'error', msg: `cannot listen on ${where.host}:${port}: ${cause}`, fix }))
2161
+ process.exit(1)
2162
+ })
2163
+ s.listen(where)
1657
2164
  // SIGTERM/SIGINT: stop accepting, answer what is in flight or arrives on open connections (each then closes), for up
1658
2165
  // to BRAIN_DRAIN_MS (10 s), then cut whatever is left; exit 0 either way
1659
2166
  const drain = () => {