@christianirack/brainllm-runtime 0.6.1 → 0.8.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
@@ -6,6 +6,7 @@
6
6
  //
7
7
  // It serves, in this order:
8
8
  // GET /_health → 200 {"ok":true,"build":"<sig>"}
9
+ // GET /health[/live|/ready] → forwarded to the native backend, or 200 {"ok":true} without one
9
10
  // POST /_r/<hash>/<fn> → RPC to a `server:` function (same origin + X-Brain-RPC; body, rate and time limits from brain.json `server`)
10
11
  // GET routes in the manifest → SSR: load(ctx) → render the view on the DOM shim → page with props and SEO head
11
12
  // GET files in dist/ → static (the smallest accepted .zst/.br/.gz sibling; /_b/* immutable), clean URLs
@@ -18,11 +19,14 @@
18
19
  // A page template (`page`) is the route's final HTML with `<!--brain:ssr-->` inside #brain-view and
19
20
  // `<!--brain:props-->` inside <script type="application/json" id="brain-props">.
20
21
  import { createServer as http, STATUS_CODES, request as httpRequest, Agent as HttpAgent } from 'node:http'
22
+ import { AsyncLocalStorage } from 'node:async_hooks'
21
23
  import net from 'node:net'
22
24
  import { installNativeSockets } from './native-sockets.mjs'
25
+ import { llmClient } from './llm.mjs'
26
+ import { BlockList, isIP } from 'node:net'
23
27
  import { Readable } from 'node:stream'
24
28
  import zlib from 'node:zlib'
25
- import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
29
+ import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
26
30
  import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
27
31
  import * as Module from 'node:module'
28
32
  import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
@@ -47,7 +51,7 @@ const SECURITY = {
47
51
  'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
48
52
  'cross-origin-opener-policy': 'same-origin',
49
53
  // 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/)',
54
+ 'x-generated-by': 'BRAINLLM (https://chrisifm.github.io/BRAINLLM/; https://github.com/chrisifm/BRAINLLM)',
51
55
  }
52
56
  // The build's policy (manifest `csp`, without hashes: the same one static hosting uses) plus this response's nonce in
53
57
  // script-src; without it, a default that lets api() reach HTTPS APIs of other origins.
@@ -68,7 +72,7 @@ const b64u = b => Buffer.from(b).toString('base64url')
68
72
  const tag = s => createHash('sha1').update(s).digest('base64url').slice(0, 22)
69
73
 
70
74
  // 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']
75
+ const LIST_OPTIONS = ['search','limit','offset','filter','sort','cursor','after','include','aggregate','count','fields','owner']
72
76
  const FILTER_OPS = ['eq','ne','gt','gte','lt','lte','between','in','exists','prefix','iprefix','has']
73
77
  export class HttpError extends Error {
74
78
  constructor(status, message, extra) {
@@ -103,6 +107,24 @@ function safeLocation(value) {
103
107
  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
108
  return value
105
109
  }
110
+ // Internal links keep the page's language (§37): `route.hrefs` is the table of a page outside the default language, the
111
+ // one brain-links.js applies in the browser; `b` is the base path, added later here.
112
+ function linkTo(route, path) {
113
+ const t = route && route.hrefs
114
+ if (!t || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path
115
+ const m = /^[^?#]*/.exec(path)[0]
116
+ const n = t.s[m] || (new RegExp(t.re).test(m) && (m === '/' ? t.p : t.p + m))
117
+ return n ? n + path.slice(m.length) : path
118
+ }
119
+ function localizeLinks(route, html) {
120
+ if (!route.hrefs) return html
121
+ const b = (globalThis.__brainBase || '/').slice(0, -1) // render() set it for this page
122
+ return html.replace(/<(?:a|form)\s[^>]*>/g, tag => /\shreflang=/.test(tag) ? tag : tag.replace(/(\s(?:href|action)=")(\/[^"]*)"/, (all, at, h) => {
123
+ const a = b && (h + '/').startsWith(b + '/') ? h.slice(b.length) || '/' : h
124
+ return at + (a === h ? linkTo(route, a) : b + linkTo(route, a)) + '"'
125
+ }))
126
+ }
127
+
106
128
  // `throw redirect('/login')` (or return it) from load/guards; same-site paths only.
107
129
  export const redirect = (url, status = 303) => {
108
130
  if (!REDIRECT_CODES.has(status)) throw new HttpError(500, 'Invalid redirect status')
@@ -187,8 +209,64 @@ function googleSignInPage(page) {
187
209
  }
188
210
 
189
211
  // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
190
- export function nativeAuthBridge(raw) {
212
+ // ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
213
+ function bucketClient(target, key, bucket) {
214
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
215
+ if (typeof bucket !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(bucket)) throw new HttpError(400, 'Invalid native bucket name')
216
+ const base = '/_brain/files/' + bucket
217
+ 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 }
218
+ const send = async (method, path, body, timeout) => {
219
+ let response
220
+ 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') }
221
+ if (response.ok) return response
222
+ await response.body?.cancel()
223
+ throw new HttpError(response.status >= 400 && response.status < 500 ? response.status : 503, 'Native file request failed')
224
+ }
225
+ const json = async response => { try { return await response.json() } catch { throw new HttpError(502, 'Native file response is invalid') } }
226
+ return Object.freeze({
227
+ async list(options = {}) {
228
+ 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')
229
+ const { limit, offset, record } = options
230
+ 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')
231
+ const q = new URLSearchParams(Object.entries({ limit, offset, record }).filter(([, v]) => v !== undefined))
232
+ return json(await send('GET', base + (q.size ? '?' + q : ''), undefined, 10000))
233
+ },
234
+ async meta(id) { return json(await send('GET', idPath(id) + '/meta', undefined, 10000)) },
235
+ async read(id, { maxBytes = 16 * 1024 * 1024 } = {}) {
236
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024) throw new HttpError(400, 'Invalid native file read limit')
237
+ const response = await send('GET', idPath(id), undefined, 30000)
238
+ 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') }
239
+ const bytes = Buffer.from(await response.arrayBuffer())
240
+ if (bytes.length > maxBytes) throw new HttpError(413, 'The file exceeds the read limit; raise maxBytes up to 64 MiB')
241
+ return { bytes, contentType: response.headers.get('content-type'), etag: response.headers.get('etag') }
242
+ },
243
+ async upload({ filename, contentType = 'application/octet-stream', data, record } = {}) {
244
+ 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')
245
+ if (typeof data !== 'string' && !(data instanceof Uint8Array)) throw new HttpError(400, 'File data must be a string, Buffer or Uint8Array')
246
+ const form = new FormData()
247
+ form.set('file', new Blob([data], { type: contentType }), filename)
248
+ return json(await send('POST', base + (record === undefined ? '' : '?record=' + record), form, 60000))
249
+ },
250
+ async remove(id) { await send('DELETE', idPath(id), undefined, 10000); return null },
251
+ })
252
+ }
253
+ export function nativeAuthBridge(raw, refused, serviceKey, report) {
254
+ // How to fix the configuration is for the operator: a visitor of a production app reads a generic message.
255
+ const configuration = (status, detail, rid, shown = 'This service is not available right now') => {
256
+ if (process.env.NODE_ENV !== 'production') return new HttpError(status, detail)
257
+ report?.('error', 'native backend configuration', { rid, fix: detail })
258
+ return new HttpError(status, shown)
259
+ }
191
260
  if (!raw) return null
261
+ // The visitor's cookies only travel for a request from the site itself; the caller's log gets the reason (B0742).
262
+ const bridgeOrigin = (incoming, rid, message) => {
263
+ try {
264
+ const origin = new URL(incoming.headers.origin)
265
+ if (origin.origin === incoming.headers.origin && origin.host === (incoming.headers['x-forwarded-host'] || incoming.headers.host)) return origin.origin
266
+ } catch {}
267
+ if (refused) refused('B0742', incoming, rid, 'native')
268
+ throw new HttpError(403, 'B0742: ' + message)
269
+ }
192
270
  if (typeof raw !== 'string' || raw.length > 2048) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
193
271
  let target
194
272
  try { target = new URL(raw) } catch { throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin') }
@@ -218,6 +296,7 @@ export function nativeAuthBridge(raw) {
218
296
  const call = (incoming, response, path, method, rid, origin) => {
219
297
  if (stopped || active >= 8) return Promise.reject(unavailable())
220
298
  const cookie = cookies(incoming, method === 'POST')
299
+ const client = forwardedFor(incoming)
221
300
  active++
222
301
  return new Promise((resolve, reject) => {
223
302
  let outgoing, timer, finished = false
@@ -231,7 +310,7 @@ export function nativeAuthBridge(raw) {
231
310
  const cancel = () => outgoing?.destroy(unavailable())
232
311
  const disconnected = () => { if (!response.writableEnded) cancel() }
233
312
  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 => {
313
+ 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
314
  let bytes = 0; const chunks = []
236
315
  if (backend.headers['content-encoding'] || Number(backend.headers['content-length']) > 16384) { backend.destroy(); return finish(true) }
237
316
  backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); finish(true) } else chunks.push(chunk) })
@@ -260,27 +339,136 @@ export function nativeAuthBridge(raw) {
260
339
  } catch { outgoing?.destroy(); finish(true) }
261
340
  })
262
341
  }
263
- let proxyBytes = 0
264
- function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024) {
342
+ // The access cookie lives 5 minutes and the session far longer: a password JWT session would read as anonymous on the
343
+ // next page. The backend answers concurrent refreshes with the same token inside its grace window, so tabs and
344
+ // prefetches cannot revoke the family. A denied refresh is anonymous; an unavailable backend stays an error.
345
+ const renew = (req, res, rid) => {
346
+ const selected = cookies(req, true)
347
+ let origin
348
+ try { origin = new URL(process.env.PUBLIC_SITE_URL).origin } catch { origin = 'https://' + (req.headers['x-forwarded-host'] || req.headers.host) }
349
+ return new Promise((resolve, reject) => {
350
+ if (stopped || active >= 8) return reject(unavailable())
351
+ active++
352
+ let settled = false
353
+ const done = (error, value) => { if (settled) return; settled = true; active--; clearTimeout(timer); error ? reject(unavailable()) : resolve(value) }
354
+ 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 => {
355
+ let bytes = 0; const chunks = []
356
+ backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); done(true) } else chunks.push(chunk) })
357
+ backend.on('error', () => done(true))
358
+ backend.on('end', () => {
359
+ if (backend.statusCode === 401 || backend.statusCode === 403) return done(false)
360
+ if (backend.statusCode !== 200) return done(true)
361
+ try {
362
+ const user = safeUser(JSON.parse(Buffer.concat(chunks).toString('utf8'))?.user)
363
+ const set = (backend.headers['set-cookie'] || []).filter(value => /^__Host-brain-(?:access|refresh)=/.test(value) && value.length <= 8192 && !/[\r\n]/.test(value))
364
+ if (!set.some(value => value.startsWith('__Host-brain-access='))) return done(true)
365
+ req.$nativeUser = user
366
+ req.$nativeLoginHeaders = { 'set-cookie': set, 'cache-control': 'no-store' }
367
+ // later backend calls of this same request use the new tokens
368
+ 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)] }))
369
+ const kept = (req.headers.cookie || '').split(';').map(part => part.trim()).filter(part => part && !fresh.has(part.split('=', 1)[0]))
370
+ req.headers.cookie = [...kept, ...[...fresh].map(([name, value]) => name + '=' + value)].join('; ')
371
+ done(false)
372
+ } catch { done(true) }
373
+ })
374
+ })
375
+ const timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
376
+ owned.add(outgoing); outgoing.once('close', () => owned.delete(outgoing))
377
+ outgoing.once('error', () => done(true)); outgoing.end()
378
+ })
379
+ }
380
+ let proxyBytes = 0, streams = 0
381
+ // A file transfer lasts as long as the client needs, so streams have their own sockets and budget and never hold API slots.
382
+ const streamAgent = new HttpAgent({ keepAlive: false, maxSockets: 32, maxTotalSockets: 32 })
383
+ 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']
384
+ const FILE_HEADERS = ['range','if-range','if-modified-since','if-unmodified-since']
385
+ const RESPONSE_HEADERS = ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data','cache-control']
386
+ const FILE_RESPONSE_HEADERS = ['content-length','content-range','accept-ranges','vary','content-security-policy','x-content-type-options','cross-origin-resource-policy','referrer-policy']
387
+ // The identity the backend resolves through trustProxy: the hops already received plus the peer this gateway observed.
388
+ // Hops to the left of an untrusted peer are ignored by the backend, so a client header can never choose the identity.
389
+ const forwardedFor = req => {
390
+ if (req.$brainForwardedFor) return req.$brainForwardedFor
391
+ const peer = req.socket?.remoteAddress
392
+ if (!peer) return undefined
393
+ const prior = req.headers['x-forwarded-for']
394
+ return typeof prior === 'string' && prior.length <= 4096 ? prior + ', ' + peer : peer
395
+ }
396
+ const requestHeaders = (req, rid, extra = []) => {
397
+ const headers = { 'x-request-id':rid }
398
+ for (const key of [...REQUEST_HEADERS, ...extra]) {
399
+ const value = req.headers[key]
400
+ if (typeof value === 'string' && value.length <= 8192) headers[key] = value
401
+ }
402
+ const selected = []
403
+ for (const part of String(req.headers.cookie || '').split(';')) {
404
+ const name = part.trim().split('=')[0]
405
+ if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
406
+ }
407
+ if (selected.length) headers.cookie = selected.join('; ')
408
+ const client = forwardedFor(req)
409
+ if (client) headers['x-forwarded-for'] = client
410
+ return headers
411
+ }
412
+ const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
413
+ // /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
414
+ function fileStream(req, res, url, rid) {
415
+ if (stopped || streams >= 32) throw new HttpError(503, 'Backend stream capacity is unavailable')
416
+ if (!['GET','HEAD','POST','DELETE'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
417
+ const ceiling = 2 * 1024 * 1024 * 1024 + 1024 * 1024
418
+ if (Number(req.headers['content-length'] || 0) > ceiling) throw new HttpError(413, 'Request body is too large')
419
+ clearTimeout(req.$brainBodyGuard)
420
+ streams++
421
+ return new Promise((resolve, reject) => {
422
+ let outgoing, deadline, done = false, sent = 0
423
+ const finish = failure => {
424
+ if (done) return
425
+ done = true; streams--; clearTimeout(deadline); owned.delete(outgoing)
426
+ req.unpipe(outgoing); req.removeListener('data', count); res.removeListener('close', closed)
427
+ if (failure && !res.headersSent && !res.destroyed) return reject(failure)
428
+ if (failure && !res.destroyed) res.destroy()
429
+ outgoing?.destroy(); resolve()
430
+ }
431
+ const closed = () => finish()
432
+ const count = chunk => { sent += chunk.length; if (sent > ceiling) finish(new HttpError(413, 'Request body is too large')) }
433
+ const unavailable = () => new HttpError(503, 'Backend request unavailable')
434
+ try {
435
+ outgoing = httpRequest(new URL(url.pathname + url.search, target), { agent:streamAgent, method:req.method, headers:requestHeaders(req, rid, FILE_HEADERS) }, backend => {
436
+ const safe = { 'cache-control':'no-store' }
437
+ for (const key of [...RESPONSE_HEADERS, ...FILE_RESPONSE_HEADERS]) {
438
+ const value = backend.headers[key]
439
+ if (typeof value === 'string' && value.length <= 8192) safe[key] = value
440
+ }
441
+ const cookies = nativeCookies(backend.headers['set-cookie'])
442
+ if (cookies.length) safe['set-cookie'] = cookies
443
+ res.writeHead(backend.statusCode || 502, safe)
444
+ backend.once('error', () => finish(unavailable()))
445
+ backend.once('aborted', () => finish(unavailable()))
446
+ backend.once('end', () => { res.end(() => finish()) })
447
+ backend.pipe(res)
448
+ })
449
+ owned.add(outgoing)
450
+ // No progress in either direction (a stalled backend, or a client that stopped reading and paused the socket).
451
+ outgoing.setTimeout(60000, () => finish(new HttpError(504, 'Backend request timed out')))
452
+ // The backend's own upload deadline is 300 s; nothing else needs a total limit after the headers.
453
+ if (req.method === 'POST') deadline = setTimeout(() => finish(new HttpError(504, 'Backend request timed out')), 310000)
454
+ outgoing.once('error', () => finish(unavailable()))
455
+ res.once('close', closed)
456
+ req.on('data', count)
457
+ req.pipe(outgoing)
458
+ } catch { finish(unavailable()) }
459
+ })
460
+ }
461
+ function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024, declared) {
265
462
  if (stopped || active >= 8) throw new HttpError(503, 'Backend request capacity is unavailable')
266
463
  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
464
+ const post = req.method === 'POST'
465
+ const limit = url.pathname === '/_brain/uploads' && post ? 5 * 1024 * 1024 + 65536 : declared || post && /^\/api\/[^/]+\/batch$/.test(url.pathname) ? 1024 * 1024 : 65536
268
466
  if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
269
467
  active++
270
468
  return new Promise((resolve, reject) => {
271
469
  let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
272
470
  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('; ')
471
+ const headers = requestHeaders(req, rid, declared?.headers)
284
472
  const finish = (error, backend) => {
285
473
  if (done) return
286
474
  done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
@@ -288,12 +476,16 @@ export function nativeAuthBridge(raw) {
288
476
  res.removeListener('close',cancel)
289
477
  if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
290
478
  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']) {
479
+ for (const key of RESPONSE_HEADERS) {
292
480
  const value = backend.headers[key]
293
481
  if (typeof value === 'string' && value.length <= 8192) safe[key] = value
294
482
  }
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)
483
+ // the response headers a route contract allows its handler to set (docs/backend/routes.md)
484
+ if (declared) for (const [key, value] of Object.entries(backend.headers)) {
485
+ if ((key === 'link' || key === 'content-language' || key.startsWith('x-') && key !== 'x-request-id') && typeof value === 'string' && value.length <= 8192) safe[key] = value
486
+ }
487
+ const cookies = nativeCookies(backend.headers['set-cookie'])
488
+ if (cookies.length) safe['set-cookie'] = cookies
297
489
  try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
298
490
  }
299
491
  const cancel = () => { outgoing?.destroy(); finish(true) }
@@ -313,17 +505,17 @@ export function nativeAuthBridge(raw) {
313
505
  backend.once('end',()=>finish(false,backend)); backend.once('error',()=>finish(true)); backend.once('aborted',()=>finish(true))
314
506
  })
315
507
  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)
508
+ 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
509
  outgoing.once('error',()=>finish(true)); req.once('aborted',cancel); res.once('close',cancel)
318
510
  req.on('data',data); req.once('end',end)
319
511
  if (req.readableEnded) end()
320
512
  } catch { outgoing?.destroy(); finish(true) }
321
513
  })
322
514
  }
323
- const resource = (incoming, response, rid, name) => {
515
+ const resource = (incoming, response, rid, name, serviceKey) => {
324
516
  if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
325
517
  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')
518
+ 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
519
  return '/api/' + name + '/' + id
328
520
  }
329
521
  const request = async (method, path, body, notFound) => {
@@ -337,11 +529,11 @@ export function nativeAuthBridge(raw) {
337
529
  }
338
530
  const req = Readable.from(bytes.length ? [bytes] : [])
339
531
  req.method = method
340
- req.headers = { cookie: cookies(incoming, false), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
532
+ req.$brainForwardedFor = forwardedFor(incoming)
533
+ // A service call acts as the key's owner, never as the visitor: it sends no session.
534
+ req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
341
535
  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
536
+ req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
345
537
  }
346
538
  let value
347
539
  try {
@@ -354,7 +546,14 @@ export function nativeAuthBridge(raw) {
354
546
  throw new HttpError(422, 'Invalid input', { errors })
355
547
  }
356
548
  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')
549
+ if (status < 200 || status >= 300) {
550
+ // the message stays generic; the backend's own code and fix (never driver text) let server code branch on it
551
+ let answer
552
+ try { answer = JSON.parse(data.toString('utf8'))?.error } catch {}
553
+ const code = typeof answer?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(answer.code) ? answer.code : undefined
554
+ const fix = code && typeof answer.fix === 'string' ? answer.fix.slice(0, 512) : undefined
555
+ throw new HttpError(status >= 400 && status < 500 ? status : 503, NATIVE_FAILED, { code, fix })
556
+ }
358
557
  if (status === 204) { value = null; return }
359
558
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
360
559
  try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
@@ -365,7 +564,8 @@ export function nativeAuthBridge(raw) {
365
564
  }
366
565
  const names = (value, label) => {
367
566
  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`)
567
+ 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}$/
568
+ 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
569
  return list.join(',')
370
570
  }
371
571
  return Object.freeze({
@@ -373,7 +573,7 @@ export function nativeAuthBridge(raw) {
373
573
  // filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
374
574
  list(options = {}) {
375
575
  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
576
+ const {search, limit, offset, filter, sort, cursor, after, include, aggregate, count, fields} = options
377
577
  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
578
  const q = new URLSearchParams()
379
579
  if (filter !== undefined) {
@@ -415,7 +615,11 @@ export function nativeAuthBridge(raw) {
415
615
  if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(value)) throw new HttpError(400, 'Invalid native list cursor')
416
616
  q.set(key, value)
417
617
  }
418
- for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count]]) if (value !== undefined) q.set(key, names(value, key))
618
+ for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count], ['fields', fields]]) if (value !== undefined) q.set(key, names(value, key))
619
+ if (options.owner !== undefined) {
620
+ if (options.owner !== 'me') throw new HttpError(400, "Invalid native list owner; use 'me'")
621
+ q.set('owner', 'me')
622
+ }
419
623
  const query = q.toString()
420
624
  return request('GET', '/api/' + name + (query ? '?' + query : ''))
421
625
  },
@@ -431,27 +635,30 @@ export function nativeAuthBridge(raw) {
431
635
  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
636
  return request('PATCH', idPath(id), values)
433
637
  },
638
+ // a declared atomic action or state transition (docs/backend/atomic-updates.md); the key still needs `<resource>:write`
639
+ action: (id, action, input = {}) => request('POST', idPath(id) + '/actions/' + encodeURIComponent(action), input),
640
+ transition: (id, transition, input = {}) => request('POST', idPath(id) + '/transitions/' + encodeURIComponent(transition), input),
434
641
  remove: id => request('DELETE', idPath(id)),
435
642
  // an earlier slug of a readable record → its current slug; live, unknown or unreadable slugs → null
436
643
  slugRedirect(slug) {
437
- if (typeof slug !== 'string' || !/^[A-Za-z0-9._~-]{1,512}$/.test(slug)) throw new HttpError(400, 'Invalid native slug')
644
+ if (typeof slug !== 'string' || !/^[A-Za-z0-9._~-]{1,512}$/.test(slug)) return Promise.resolve(null)
438
645
  return request('GET', '/api/' + name + '/redirects/' + slug, undefined, () => null).then(value => typeof value?.slug === 'string' ? value.slug : null)
439
646
  },
440
647
  })
441
648
  }
442
649
  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')
650
+ 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
651
  if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
445
652
  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')}
653
+ const origin=bridgeOrigin(incoming,rid,'Sign in through a same-origin POST server function')
448
654
  incoming.$nativeLoginStarted=true
449
655
  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)}
656
+ 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
657
  let user
452
658
  try {
453
659
  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')
660
+ 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")
661
+ 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
662
  let payload
456
663
  try {payload=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
457
664
  user=safeUser(payload?.user)
@@ -464,11 +671,88 @@ export function nativeAuthBridge(raw) {
464
671
  } finally {request.destroy();bytes.fill(0)}
465
672
  return {user}
466
673
  }
674
+ // Creates the account and leaves the session alone: `await ctx.backend.signup(v); return ctx.backend.login(v)` signs in
675
+ // too. A refused address is the email field's error: the same 422 for an invalid one and for one already taken, and no
676
+ // more than any signup form reveals by succeeding.
677
+ async function signup(incoming,response,rid,credentials) {
678
+ if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'B0742: Sign up through a same-origin POST server function')
679
+ 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')
680
+ 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'}})
681
+ const origin=bridgeOrigin(incoming,rid,'Sign up through a same-origin POST server function')
682
+ const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
683
+ 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)}
684
+ let user
685
+ try {
686
+ await proxy(request,response,new URL('/_brain/auth/signup',target),rid,(_res,status,data)=>{
687
+ 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")
688
+ if(status===422)throw new HttpError(422,'This email cannot be used',{errors:{email:'This email cannot be used'}})
689
+ if(status===429)throw new HttpError(429,'Too many attempts. Wait before trying again.')
690
+ if(status!==200&&status!==201)throw unavailable()
691
+ try {user=safeUser(JSON.parse(data.toString('utf8'))?.user)}catch{throw unavailable()}
692
+ },16384)
693
+ } finally {request.destroy();bytes.fill(0)}
694
+ return {user}
695
+ }
696
+ // Google needs its nonce bound to the browser that asked for it, so both steps relay the HttpOnly nonce cookie. The page
697
+ // only ever sees the public client id and nonce it hands to Google.
698
+ const sessionCookie = /^(?:__Host-brain|__Host-brain-access|__Host-brain-refresh|__Host-brain-google)=/
699
+ const strictCookie = value => /(?:^|;)\s*HttpOnly(?:;|$)/i.test(value) && /(?:^|;)\s*Secure(?:;|$)/i.test(value) && /(?:^|;)\s*Path=\/(?:;|$)/i.test(value) && /(?:^|;)\s*SameSite=Lax(?:;|$)/i.test(value)
700
+ async function googleStep(incoming,response,rid,path,payload,cookie,read) {
701
+ if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
702
+ if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
703
+ let origin
704
+ 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')}
705
+ incoming.$nativeLoginStarted=true
706
+ const bytes=Buffer.from(JSON.stringify(payload))
707
+ 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}:{})}
708
+ let result
709
+ try {
710
+ await proxy(request,response,new URL(path,target),rid,(_res,status,data,headers)=>{
711
+ 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')
712
+ let parsed
713
+ try {parsed=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
714
+ const selected=headers['set-cookie']||[]
715
+ 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()
716
+ result=read(parsed)
717
+ if(response.writableEnded||response.destroyed)throw unavailable()
718
+ incoming.$nativeLoginHeaders={'set-cookie':selected,'cache-control':'no-store'}
719
+ },16384)
720
+ } finally {request.destroy();bytes.fill(0)}
721
+ return result
722
+ }
723
+ const googleChallenge = (incoming,response,rid) => googleStep(incoming,response,rid,'/_brain/auth/google/challenge',{},null,payload=>{
724
+ if(typeof payload?.client_id!=='string'||typeof payload?.nonce!=='string'||!/^[a-f0-9]{64}$/.test(payload.nonce)||!Number.isInteger(payload?.expires_in))throw unavailable()
725
+ return {client_id:payload.client_id,nonce:payload.nonce,expires_in:payload.expires_in}
726
+ })
727
+ const googleLogin = (incoming,response,rid,values) => {
728
+ 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')
729
+ const nonce=String(incoming.headers.cookie||'').split(';').map(part=>part.trim()).find(part=>part.startsWith('__Host-brain-google='))?.slice('__Host-brain-google='.length)
730
+ if(!nonce||!/^[a-f0-9]{64}$/.test(nonce))throw new HttpError(401,'Google sign-in was not accepted')
731
+ return googleStep(incoming,response,rid,'/_brain/auth/google',{credential:values.credential},'__Host-brain-google='+nonce,payload=>{
732
+ const user=safeUser(payload?.user)
733
+ return {user}
734
+ })
735
+ }
467
736
  // ctx.backend for one server request (load, actions, RPC, dev sidecar): the same object everywhere
468
737
  const backend = (incoming, response, rid) => Object.freeze({
469
738
  resource: name => resource(incoming, response, rid, name),
739
+ // A server-only resource (`api: false`) from trusted server code, with BRAIN_SERVICE_API_KEY
740
+ service: name => {
741
+ if (!serviceKey) throw configuration(503, 'ctx.backend.service needs BRAIN_SERVICE_API_KEY: create an API key with a scope naming the resource')
742
+ return resource(incoming, response, rid, name, serviceKey)
743
+ },
744
+ // A declared file bucket from trusted server code, with BRAIN_SERVICE_API_KEY and its files.<bucket> scope
745
+ files: bucket => {
746
+ if (!serviceKey) throw configuration(503, 'ctx.backend.files needs BRAIN_SERVICE_API_KEY: create an API key with a files.<bucket> scope')
747
+ return bucketClient(target, serviceKey, bucket)
748
+ },
470
749
  slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
471
750
  login: credentials => login(incoming, response, rid, credentials),
751
+ signup: credentials => signup(incoming, response, rid, credentials),
752
+ google: Object.freeze({
753
+ challenge: () => googleChallenge(incoming, response, rid),
754
+ login: values => googleLogin(incoming, response, rid, values),
755
+ }),
472
756
  })
473
757
  return {
474
758
  proxy,
@@ -476,21 +760,23 @@ export function nativeAuthBridge(raw) {
476
760
  backend,
477
761
  login,
478
762
  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 })
763
+ if (!cookies(req, true)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
764
+ if (!req.$nativeAuth) req.$nativeAuth = (async () => {
765
+ req.$nativeUser = cookies(req, false) ? (await call(req, res, '/_brain/auth/me', 'GET', rid)).user : null
766
+ if (req.$nativeUser === null && /(?:^|;\s*)__Host-brain-refresh=/.test(req.headers.cookie || '')) await renew(req, res, rid)
767
+ })()
481
768
  await req.$nativeAuth
482
769
  },
483
770
  session(req, res, rid) {
484
771
  return {
485
772
  user: req.$nativeUser,
486
- set() { throw new HttpError(409, 'Use the native authentication API to establish a session') },
773
+ 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
774
  clear() {
488
775
  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') }
776
+ if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'B0742: Sign out through a same-origin POST request')
777
+ const origin = bridgeOrigin(req, rid, 'Sign out through a same-origin POST request')
492
778
  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 })
779
+ 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
780
  req.$nativeLogout.catch(() => {})
495
781
  }
496
782
  return req.$nativeLogout
@@ -498,15 +784,23 @@ export function nativeAuthBridge(raw) {
498
784
  headers: () => req.$nativeLoginHeaders || {},
499
785
  }
500
786
  },
787
+ fileStream,
501
788
  _socketCookie: req => cookies(req, false),
502
- close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy() },
789
+ _forwardedFor: forwardedFor,
790
+ close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy(); streamAgent.destroy() },
503
791
  }
504
792
  }
505
793
 
506
794
  export function createServer(o = {}) {
507
795
  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)
796
+ // ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
797
+ 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
798
+ const env = Object.freeze(visibleEnv)
799
+ const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry))
800
+ // Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
801
+ const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
802
+ if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
803
+ const nativeSession = native && backendSession !== 'app'
510
804
  if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
511
805
  const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
512
806
  const secret = o.secret ?? env.BRAIN_SECRET ?? ''
@@ -532,6 +826,20 @@ export function createServer(o = {}) {
532
826
  if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
533
827
  sink(level, msg, safe)
534
828
  }
829
+ // An origin refusal (B0741, B0742): the answer stays minimal and the operator's log gets the reason. It is bounded per
830
+ // minute because anyone can trigger it, and it never names an allowlist: this server only compares against its own host.
831
+ let originWindow = 0, originLogs = 0
832
+ const originRefused = (code, req, rid, kind) => {
833
+ const minute = Math.floor(Date.now() / 60000)
834
+ if (originWindow !== minute) { originWindow = minute; originLogs = 0 }
835
+ if (originLogs++ >= 16) return
836
+ const bound = v => typeof v === 'string' ? v.slice(0, 256) : null
837
+ const seenOrigin = bound(req.headers.origin), host = bound(req.headers.host), forwardedHost = bound(req.headers['x-forwarded-host'])
838
+ const fix = seenOrigin
839
+ ? `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}`
840
+ : `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}`
841
+ log('error', 'origin refused', { code, kind, rid, seenOrigin, secFetchSite: bound(req.headers['sec-fetch-site']), host, forwardedHost, fix })
842
+ }
535
843
  // log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
536
844
  const trace = e => {
537
845
  const out = { stack: String((e && e.stack) || e) }
@@ -546,6 +854,8 @@ export function createServer(o = {}) {
546
854
  const SL = mf.server || {}
547
855
  const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? SL.timeoutMs ?? 10000)
548
856
  const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? SL.rpcPerMinute ?? 60) // per IP + function, per minute
857
+ // Off by default: nginx below 1.29 takes a 103 as the final response, and the real one reaches the client as body bytes.
858
+ const EARLY_HINTS = o.earlyHints ?? (env.BRAIN_EARLY_HINTS ? env.BRAIN_EARLY_HINTS === '1' : SL.earlyHints === true)
549
859
  const BODY_KB = +(o.maxBodyKB ?? env.BRAIN_MAX_BODY_KB ?? SL.maxBodyKB ?? 1024)
550
860
  if (!Number.isInteger(BODY_KB) || BODY_KB < 1 || BODY_KB > 65536) throw Error('brain.json server.maxBodyKB must be an integer from 1 to 65536 (KB)')
551
861
  const MAX_BODY = BODY_KB * 1024 // RPC and form bodies; bounded reads never buffer more
@@ -557,9 +867,25 @@ export function createServer(o = {}) {
557
867
  const hasManifestSockets = Object.hasOwn(mf,'nativeSockets')
558
868
  if (hasManifestSockets && typeof mf.nativeSockets !== 'boolean') throw Error('Manifest nativeSockets must be an explicit boolean')
559
869
  const nativeSockets = o.nativeSockets === undefined ? hasManifestSockets && mf.nativeSockets : o.nativeSockets
560
- if (nativeSockets && !native) throw Error('Native sockets require a configured native backend')
870
+ if (nativeSockets && !native) throw Error('Native sockets require a configured native backend; fix: run `brain backend serve` and set BRAIN_BACKEND_URL to its loopback URL')
561
871
  // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
562
872
  const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
873
+ // The address RPC and form limits key on, ctx.req.ip and the logs: a client header counts only through a trusted proxy.
874
+ const proxies = trustedProxies(o.trustProxy ?? (env.BRAIN_TRUST_PROXY ? env.BRAIN_TRUST_PROXY.split(',').map(entry => entry.trim()) : mf.trustProxy) ?? [])
875
+ let proxyWarned = false
876
+ const ipOf = req => {
877
+ const forwarded = req.headers['x-forwarded-for']
878
+ if (forwarded && !proxies && !proxyWarned && /^(?:127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || '')) {
879
+ proxyWarned = true
880
+ 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' })
881
+ }
882
+ return resolveClient(req.socket.remoteAddress, forwarded, proxies)
883
+ }
884
+ // server/routes/* of the native backend, listed by the build: only these exact shapes cross the bridge
885
+ const nativeRoutes = (Array.isArray(mf.nativeRoutes) ? mf.nativeRoutes : []).map(route => {
886
+ if (typeof route?.path !== 'string' || !Array.isArray(route.headers)) throw Error('Manifest nativeRoutes must list path and headers')
887
+ return { headers: route.headers, rx: new RegExp('^' + route.path.split('/').map(part => /^\{.+\}$/.test(part) ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('/') + '$') }
888
+ })
563
889
  // the real path: Node imports file URLs through realpath, and the /_b/ hook must name the same modules (a symlinked
564
890
  // dist such as /var → /private/var on macOS would otherwise load the core twice)
565
891
  const distURL = pathToFileURL(real(dist) + sep).href
@@ -675,6 +1001,13 @@ export function createServer(o = {}) {
675
1001
  const path = typeof custom === 'string' && file(custom)
676
1002
  return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
677
1003
  }
1004
+ // `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
1005
+ const reservedStatus = pathname => {
1006
+ const m = /^\/(403|404|410|5\d\d)(?:\.html)?$/.exec(pathname)
1007
+ if (!m) return 0
1008
+ const n = +m[1]
1009
+ return file(`${n}.html`) || (mf.errors && mf.errors[n]) || (n === 403 && mf.forbidden) || (n === 404 && mf.notFound) ? n : 0
1010
+ }
678
1011
  // a file inside dist/ (never outside: no `..`, no absolute paths)
679
1012
  function file(rel) {
680
1013
  const p = normalize(join(dist, rel))
@@ -695,13 +1028,14 @@ export function createServer(o = {}) {
695
1028
 
696
1029
  const context = (req, res, url, extra) => {
697
1030
  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)
1031
+ 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)
1032
+ const signal = lifetime(res)
699
1033
  return {
700
1034
  url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
701
1035
  ...(native ? { backend: native.backend(req, res, res.getHeader('x-request-id')) } : {}),
702
1036
  // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
703
- prefetch: req.headers['x-brain-prefetch'] === '1',
704
- req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
1037
+ prefetch: req.headers['x-brain-prefetch'] === '1' || /\bprefetch\b/.test(req.headers['sec-purpose'] ?? ''),
1038
+ req: { method: req.method, headers: req.headers, ip: ipOf(req) }, signal, llm: llmClient(env, signal), ...extra,
705
1039
  }
706
1040
  }
707
1041
 
@@ -755,19 +1089,24 @@ export function createServer(o = {}) {
755
1089
  const anonymous = req => req.method === 'GET' && !req.headers.authorization && !req.headers.cookie
756
1090
 
757
1091
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
758
- // 103 Early Hints (#232): the route's preloads (the same Link values as _headers) before any work, once
759
- if (route.links && route.links.length && res.writeEarlyHints && !res.$capture && !res.$hinted) {
1092
+ // the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
1093
+ // capped so a long list cannot trip a proxy's header buffer. A 103 (#232) only when opted in, and never to HTTP/1.0.
1094
+ if (route.links && route.links.length && !res.$capture && !res.$hinted) {
760
1095
  res.$hinted = true
761
- try {
762
- res.writeEarlyHints({ link: route.links })
763
- } catch {}
1096
+ const link = route.links.join(', ')
1097
+ if (link.length <= 2048 && !res.headersSent) res.setHeader('link', link)
1098
+ if (EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1099
+ try {
1100
+ res.writeEarlyHints({ link: route.links })
1101
+ } catch {}
1102
+ }
764
1103
  }
765
1104
  if (!nocache && route.cache > 0 && !forms && !fixed && status === 200 && anonymous(req)) return cached(req, res, route, params, url, rid)
766
1105
  if (native) await native.prepare(req, res, rid)
767
1106
  const ctx = context(req, res, url, { params })
768
1107
  // a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
769
1108
  // 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 || '')
1109
+ 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
1110
  const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
772
1111
  let props = fixed || {}, extra = {}
773
1112
  if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
@@ -776,19 +1115,19 @@ export function createServer(o = {}) {
776
1115
  const guards = [], middleware = []
777
1116
  for (const g of route.guards || []) guards.push(await load(g))
778
1117
  for (const m of route.middleware || []) middleware.push(await load(m))
779
- r = await runChain({ guards, middleware, ctx })
1118
+ r = await pageLocale.run(route.locale, () => runChain({ guards, middleware, ctx }))
780
1119
  } catch (e) {
781
1120
  log('error', 'guard failed', { rid, path: url.pathname, ...trace(e) })
782
1121
  return errorPage(req, res, route, params, url, rid)
783
1122
  }
784
1123
  // 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() })
1124
+ 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
1125
  if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
787
1126
  extra = r.headers
788
1127
  }
789
1128
  // what load() thrown or returned instead of props answers: redirect, notFound() (F11), deliberate status, failure
790
1129
  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() })
1130
+ 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
1131
  if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return missing(req, res, e) // `notFound()` 404 / `gone()` 410
793
1132
  const status = intendedStatus(e) || 500
794
1133
  log('error', 'load failed', { rid, path: url.pathname, status, ...trace(e) })
@@ -798,7 +1137,8 @@ export function createServer(o = {}) {
798
1137
  try {
799
1138
  if (!fixed && route.server) {
800
1139
  const m = await load(route.server)
801
- if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
1140
+ if (route.i18n) await load(route.i18n) // t() inside load(): the dictionary is there before it runs
1141
+ if (typeof m.load === 'function') props = (await withTimeout(pageLocale.run(route.locale, () => Promise.resolve(m.load(ctx))), TIMEOUT, ctx)) ?? {}
802
1142
  }
803
1143
  } catch (e) {
804
1144
  return loadOutcome(e)
@@ -817,7 +1157,9 @@ export function createServer(o = {}) {
817
1157
  const page = readFileSync(file(route.page), 'utf8')
818
1158
  .replace('<!--brain:ssr-->', () => body)
819
1159
  .replace('<!--brain:props-->', () => jsonInHtml(props))
820
- let out = head(page, seo, url.pathname)
1160
+ // a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
1161
+ if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
1162
+ let out = head(page, seo, url.pathname, url.search)
821
1163
  // route params for route() in the browser (the client has no route table)
822
1164
  if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
823
1165
  if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
@@ -833,7 +1175,8 @@ export function createServer(o = {}) {
833
1175
  async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
834
1176
  const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
835
1177
  const sm = await load(route.server)
836
- const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
1178
+ if (route.i18n) await load(route.i18n)
1179
+ const loadP = typeof sm.load === 'function' ? withTimeout(pageLocale.run(route.locale, () => Promise.resolve().then(() => sm.load(ctx))), TIMEOUT, ctx) : Promise.resolve(null)
837
1180
  loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
838
1181
  let timer
839
1182
  const early = await Promise.race([loadP.then(v => ({ v }), e => ({ e })), new Promise(r => (timer = setTimeout(r, STREAM_HOLD)))])
@@ -860,7 +1203,7 @@ export function createServer(o = {}) {
860
1203
  globalThis.__brainLocale = null
861
1204
  }
862
1205
  const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
863
- write(n(head(top, seo0, url.pathname)))
1206
+ write(n(head(top, seo0, url.pathname, url.search)))
864
1207
  // QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
865
1208
  // between two marker comments; the last chunk starts with a script that removes them and what is between
866
1209
  let clear = ''
@@ -892,7 +1235,7 @@ export function createServer(o = {}) {
892
1235
  if (props.redirect || props.notFound === true) throw props
893
1236
  } catch (e) {
894
1237
  // 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>`)
1238
+ 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
1239
  // a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
897
1240
  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
1241
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
@@ -966,7 +1309,7 @@ export function createServer(o = {}) {
966
1309
  await new Promise(r => setTimeout(r, 0))
967
1310
  globalThis.__brainSSR = null
968
1311
  fillValues(root)
969
- const body = root.innerHTML
1312
+ const body = localizeLinks(route, root.innerHTML)
970
1313
  stop()
971
1314
  // SEO head: the codegen emits `export const seo` / `head` in the view's server module; the client chunk's V.seo
972
1315
  // (older builds) is the fallback. Still inside the page's locale: `<meta seo title={t('…')}>` is translated.
@@ -987,7 +1330,7 @@ export function createServer(o = {}) {
987
1330
  const fail = (status, error, { $h, ...extra } = {}) => send(res, status, json({ error, ...extra }), { 'content-type': MIME['.json'], 'cache-control': 'no-store', ...$h })
988
1331
  if (req.method !== 'POST') return fail(405, 'Use POST', { $h: { allow: 'POST' } })
989
1332
  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')
1333
+ if (!sameOrigin(req)) return originRefused('B0741', req, rid, 'rpc'), fail(403, 'Cross-origin request', { brainCode: 'B0741' })
991
1334
  const modPath = mf.rpc[hash]
992
1335
  if (!modPath || !/^[A-Za-z_$][\w$]*$/.test(fn)) return fail(404, 'Unknown function')
993
1336
  const ra = overLimit(ipOf(req) + ' ' + hash + '/' + fn, LIMIT) // before reading the body
@@ -995,7 +1338,8 @@ export function createServer(o = {}) {
995
1338
  let raw
996
1339
  try {
997
1340
  raw = await body(req, MAX_BODY)
998
- } catch {
1341
+ } catch (e) {
1342
+ if (e.aborted) return
999
1343
  return fail(413, tooBig(rid, url.pathname))
1000
1344
  }
1001
1345
  if (native) await native.prepare(req, res, rid)
@@ -1030,12 +1374,13 @@ export function createServer(o = {}) {
1030
1374
  // ---------- form posted without JS (§38; transport, the logic is callForm, shared with the dev server) ----------
1031
1375
  async function formPost(req, res, route, params, url, rid) {
1032
1376
  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')
1377
+ if (!sameOrigin(req, true)) return originRefused('B0741', req, rid, 'form'), plain(403, 'Cross-origin form post', { 'x-brain-code': 'B0741' })
1034
1378
  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
1379
  let raw
1036
1380
  try {
1037
1381
  raw = await body(req, MAX_BODY)
1038
- } catch {
1382
+ } catch (e) {
1383
+ if (e.aborted) return
1039
1384
  return plain(413, tooBig(rid, url.pathname))
1040
1385
  }
1041
1386
  const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
@@ -1215,8 +1560,29 @@ export function createServer(o = {}) {
1215
1560
  return false
1216
1561
  }
1217
1562
 
1563
+ // /health is reserved for the backend (B0735), so an app cannot declare it and deploy gates ask this port for it. An app
1564
+ // page at that path still wins: this only runs where the answer would have been a 404. Nothing here names the backend.
1565
+ const isProbe = path => path === '/health' || path === '/health/live' || path === '/health/ready'
1566
+ const answerProbe = async (req, res, url, rid) => {
1567
+ const headers = { 'content-type': MIME['.json'], 'cache-control': 'no-store' }
1568
+ if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
1569
+ if (!native) return send(res, 200, json({ ok: true }), headers)
1570
+ try { await native.proxy(req, res, new URL(url.pathname, url), rid, send) }
1571
+ catch { if (!res.headersSent) send(res, 503, json({ ok: false }), headers) }
1572
+ }
1573
+
1218
1574
  // ---------- dispatch ----------
1219
1575
  const server = http(async (req, res) => {
1576
+ // Only a file upload may outlast the 30-second body budget; the dispatcher lifts this guard for that path alone.
1577
+ if (native) {
1578
+ req.$brainBodyGuard = setTimeout(() => {
1579
+ if (req.complete) return
1580
+ if (!res.headersSent) res.writeHead(408, { connection:'close' })
1581
+ res.end(); req.socket?.destroy()
1582
+ }, 30000)
1583
+ req.$brainBodyGuard.unref?.()
1584
+ res.once('close', () => clearTimeout(req.$brainBodyGuard))
1585
+ }
1220
1586
  const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
1221
1587
  res.setHeader('x-request-id', rid)
1222
1588
  const t0 = performance.now()
@@ -1225,17 +1591,22 @@ export function createServer(o = {}) {
1225
1591
  if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
1226
1592
  })
1227
1593
  try {
1594
+ // `//` makes new URL throw and `//host/x` would be read as another host
1595
+ if (req.url.startsWith('//')) return notFound(req, res)
1228
1596
  const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
1229
1597
  // /_health stays at the root: the supervisor calls it there whatever the base
1230
1598
  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' })
1231
1599
  let url = full
1232
1600
  if (BASE !== '/') {
1233
1601
  if (full.pathname === BASE.slice(0, -1)) return send(res, 301, '', { location: BASE + full.search })
1234
- if (!full.pathname.startsWith(BASE)) return notFound(req, res)
1602
+ if (!full.pathname.startsWith(BASE)) return isProbe(full.pathname) ? await answerProbe(req, res, full, rid) : notFound(req, res)
1235
1603
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
1236
1604
  }
1237
1605
  req.$brainPath = url.pathname
1606
+ if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
1238
1607
  if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
1608
+ const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
1609
+ if (declaredRoute) return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
1239
1610
  // redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
1240
1611
  // same-site targets get the base, the query is kept when the target has none; slash policy and host in the same hop
1241
1612
  const to = (req.method === 'GET' || req.method === 'HEAD') && canonicalOf(req, url)
@@ -1246,6 +1617,10 @@ export function createServer(o = {}) {
1246
1617
  if (url.pathname.startsWith('/_server/')) return notFound(req, res) // server code is never served
1247
1618
  const post = req.method === 'POST'
1248
1619
  if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
1620
+ // a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
1621
+ const reserved = post ? 0 : reservedStatus(url.pathname)
1622
+ if (reserved === 403) return forbidden(req, res, url, rid, '')
1623
+ if (reserved) return failPage(res, reserved, rid)
1249
1624
  for (const route of routes) {
1250
1625
  const m = route.rx.exec(url.pathname)
1251
1626
  if (!m) continue
@@ -1257,6 +1632,9 @@ export function createServer(o = {}) {
1257
1632
  if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
1258
1633
  if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return
1259
1634
  if (serveStatic(req, res, url)) return
1635
+ // browsers still ask for /favicon.ico: the app's SVG icon answers it
1636
+ if (url.pathname === '/favicon.ico' && file('favicon.svg')) return send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
1637
+ if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
1260
1638
  notFound(req, res)
1261
1639
  } catch (e) {
1262
1640
  log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
@@ -1265,29 +1643,114 @@ export function createServer(o = {}) {
1265
1643
  }
1266
1644
  })
1267
1645
  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})
1646
+ if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,client:native._forwardedFor,log})
1269
1647
  server.headersTimeout = 15000
1270
- server.requestTimeout = 30000
1648
+ // With a native backend the file upload deadline is enforced per request (above); 30 s stays for every other body.
1649
+ server.requestTimeout = native ? 320000 : 30000
1271
1650
  return server
1272
1651
  }
1273
1652
 
1274
1653
  // ---------- 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).
1654
+ const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
1655
+ // Keys that still verify and decrypt during a rotation; new cookies always use the current key.
1656
+ const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
1657
+ const sessionVersion = () => process.env.BRAIN_SESSION_VERSION || ''
1658
+ const sessionSeconds = () => { const n = Number(process.env.BRAIN_SESSION_SECONDS); return Number.isInteger(n) && n > 0 ? n : DEFAULT_SESSION_SECONDS }
1659
+ // Cookies of this process signed out before their expiry (mac -> exp). One process only: a shared store does not exist
1660
+ // here, so a deployment with several processes signs everybody out with BRAIN_SESSION_VERSION instead.
1661
+ const REVOKED = new Map(), REVOKED_MAX = 10000
1662
+ const revoke = (mac, exp) => {
1663
+ const now = Date.now()
1664
+ for (const [k, e] of REVOKED) if (e <= now) REVOKED.delete(k)
1665
+ while (REVOKED.size >= REVOKED_MAX) REVOKED.delete(REVOKED.keys().next().value)
1666
+ REVOKED.set(mac, exp)
1667
+ }
1668
+ const secretKey = secret => Buffer.from(hkdfSync('sha256', secret, '', 'brain session secret v1', 32))
1669
+ const seal = (secret, text) => {
1670
+ const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', secretKey(secret), iv)
1671
+ cipher.setAAD(Buffer.from(SECRET_COOKIE))
1672
+ const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
1673
+ return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64url')
1674
+ }
1675
+ const unseal = (secret, value) => {
1676
+ try {
1677
+ const raw = Buffer.from(value, 'base64url')
1678
+ if (raw.length < 29) return null
1679
+ const decipher = createDecipheriv('aes-256-gcm', secretKey(secret), raw.subarray(0, 12))
1680
+ decipher.setAAD(Buffer.from(SECRET_COOKIE))
1681
+ decipher.setAuthTag(raw.subarray(12, 28))
1682
+ return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
1683
+ } catch { return null }
1684
+ }
1685
+ // `ctx.session.setSecret(value, { maxAge })`, `.secret()` and `.clearSecret()` on any session handle (signed cookie or
1686
+ // native bridge): server-only values such as a provider token, AES-256-GCM encrypted in their own cookie. They never
1687
+ // fill `ctx.user`, and `session.clear()` removes them too.
1688
+ 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)`)
1689
+ export function sessionSecrets(session, cookies, secret) {
1690
+ const out = []
1691
+ const write = (value, maxAge) => { out.length = 0; out.push(`${SECRET_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${Math.max(0, maxAge)}`) }
1692
+ let current
1693
+ const read = () => {
1694
+ if (current !== undefined) return current
1695
+ current = null
1696
+ const v = cookies && cookies[SECRET_COOKIE]
1697
+ if (!v || !secret) return current
1698
+ for (const key of [secret, ...previousKeys()]) {
1699
+ const text = unseal(key, v)
1700
+ if (text === null) continue
1701
+ try {
1702
+ const s = JSON.parse(text)
1703
+ if (s && s.exp > Date.now()) current = s.v
1704
+ } catch {}
1705
+ break
1706
+ }
1707
+ return current
1708
+ }
1709
+ const { clear, headers } = session
1710
+ const clearSecret = () => { write('', 0); current = null }
1711
+ return Object.assign(session, {
1712
+ secret: read,
1713
+ setSecret(value, { maxAge = sessionSeconds() } = {}) {
1714
+ if (!secret) throw missingSecret('session secrets')
1715
+ const text = JSON.stringify({ v: value, exp: Date.now() + maxAge * 1000 })
1716
+ if (value === undefined || text === undefined || JSON.parse(text).v === undefined) throw new Error('session.setSecret needs a JSON value')
1717
+ const sealed = seal(secret, text)
1718
+ 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`)
1719
+ write(sealed, maxAge)
1720
+ current = value
1721
+ },
1722
+ clearSecret,
1723
+ clear(...args) { const result = clear.apply(session, args); if (out.length || (cookies && cookies[SECRET_COOKIE] !== undefined)) clearSecret(); return result },
1724
+ headers() {
1725
+ const base = headers.call(session)
1726
+ if (!out.length) return base
1727
+ const own = base['set-cookie']
1728
+ return { ...base, 'set-cookie': [...(Array.isArray(own) ? own : own ? [own] : []), ...out] }
1729
+ },
1730
+ })
1731
+ }
1732
+ // The user of a request: the signed `__Host-session` cookie (HMAC-SHA256 with BRAIN_SECRET; BRAIN_SECRET_PREVIOUS still
1733
+ // verifies during a rotation, BRAIN_SESSION_VERSION signs everybody out), plus the session handle to set/clear it. Same
1734
+ // in `node server.mjs` and in `brain run` (the dev sidecar imports it).
1278
1735
  export function sessionFrom(cookies, secret) {
1279
- const sign = data => b64u(createHmac('sha256', secret).update(data).digest())
1280
- let cur = null
1736
+ const signWith = (key, data) => b64u(createHmac('sha256', key).update(data).digest())
1737
+ const sign = data => signWith(secret, data)
1738
+ let cur = null, curMac = null
1281
1739
  const v = cookies && cookies[COOKIE]
1282
1740
  if (v && secret) {
1283
1741
  const i = v.lastIndexOf('.')
1284
1742
  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 {}
1743
+ const data = v.slice(0, i), mac = Buffer.from(v.slice(i + 1))
1744
+ for (const key of [secret, ...previousKeys()]) {
1745
+ const good = Buffer.from(signWith(key, data))
1746
+ if (mac.length === good.length && timingSafeEqual(mac, good)) {
1747
+ try {
1748
+ const s = JSON.parse(Buffer.from(data, 'base64url').toString())
1749
+ if (s && s.exp > Date.now() && (s.sv ?? '') === sessionVersion() && !REVOKED.has(v.slice(i + 1))) { cur = s; curMac = v.slice(i + 1) }
1750
+ } catch {}
1751
+ break
1752
+ }
1753
+ }
1291
1754
  }
1292
1755
  }
1293
1756
  const set = []
@@ -1296,14 +1759,16 @@ export function sessionFrom(cookies, secret) {
1296
1759
  const user = cur ? cur.u : null
1297
1760
  return {
1298
1761
  user,
1299
- session: {
1762
+ session: sessionSecrets({
1300
1763
  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 }))
1764
+ set(u, { maxAge = sessionSeconds() } = {}) {
1765
+ if (!secret) throw missingSecret('sessions')
1766
+ const version = sessionVersion()
1767
+ const data = b64u(JSON.stringify({ u, exp: Date.now() + maxAge * 1000, ...(version ? { sv: version } : {}) }))
1304
1768
  set.push(cookie(`${data}.${sign(data)}`, maxAge))
1305
1769
  },
1306
1770
  clear() {
1771
+ if (curMac) revoke(curMac, cur.exp)
1307
1772
  set.push(cookie('', 0))
1308
1773
  cleared = true
1309
1774
  },
@@ -1312,7 +1777,7 @@ export function sessionFrom(cookies, secret) {
1312
1777
  // response, so the logout call took ~18 s (F16). Nothing private is in that cache: RPC is no-store and SSR
1313
1778
  // HTML rendered with a session cookie is `private, no-store` (ssr() → keep).
1314
1779
  headers: () => ({ ...(set.length ? { 'set-cookie': set } : {}), ...(cleared ? { 'clear-site-data': '"storage"' } : {}) }),
1315
- },
1780
+ }, cookies, secret),
1316
1781
  }
1317
1782
  }
1318
1783
 
@@ -1378,9 +1843,30 @@ export function overLimit(key, max = 60) {
1378
1843
  if (!h || now - h[0] > 60000) HITS.set(key, (h = [now, 0]))
1379
1844
  return ++h[1] > max ? Math.ceil((h[0] + 60000 - now) / 1000) : 0
1380
1845
  }
1381
- const withTimeout = (p, ms) => {
1846
+ // t(), fmt and locale() read globalThis.__brainLocale. A render sets it while nothing else runs (renders are serialized);
1847
+ // load(), guards and form handlers overlap across awaits, so each carries its page's language in the async context.
1848
+ const pageLocale = new AsyncLocalStorage()
1849
+ let renderLocale = globalThis.__brainLocale
1850
+ Object.defineProperty(globalThis, '__brainLocale', { configurable: true, get: () => pageLocale.getStore() ?? renderLocale, set: v => (renderLocale = v) })
1851
+ // ctx.signal: aborted when the client disconnects before the answer ends or the function passes its deadline, so the
1852
+ // work it started (fetch, a query) stops with it.
1853
+ const aborts = new WeakMap()
1854
+ const newSignal = () => {
1855
+ const c = new AbortController()
1856
+ aborts.set(c.signal, c)
1857
+ return c.signal
1858
+ }
1859
+ const lifetime = res => {
1860
+ const signal = newSignal()
1861
+ res.once?.('close', () => res.writableFinished || aborts.get(signal).abort()) // a captured render has no socket
1862
+ return signal
1863
+ }
1864
+ const withTimeout = (p, ms, ctx) => {
1382
1865
  let t
1383
- return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => rej(new HttpError(504, 'timeout')), ms)))]).finally(() => clearTimeout(t))
1866
+ return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => {
1867
+ aborts.get(ctx?.signal)?.abort()
1868
+ rej(new HttpError(504, 'timeout'))
1869
+ }, ms)))]).finally(() => clearTimeout(t))
1384
1870
  }
1385
1871
  // RPC body → arguments: a JSON array, with __proto__/constructor/prototype keys dropped. Throws HttpError(400).
1386
1872
  export function parseArgs(raw) {
@@ -1408,7 +1894,8 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
1408
1894
  return { status: e.status || 400, body: { error: e.message } }
1409
1895
  }
1410
1896
  try {
1411
- const v = await withTimeout(Promise.resolve(fn(ctx, ...a)), timeout)
1897
+ ctx.signal ||= newSignal() // the dev sidecar builds its own ctx
1898
+ const v = await withTimeout(Promise.resolve(fn(ctx, ...a)), timeout, ctx)
1412
1899
  return { status: 200, body: v ?? null }
1413
1900
  } catch (e) {
1414
1901
  if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
@@ -1416,7 +1903,7 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
1416
1903
  const pub = publicError(e)
1417
1904
  if (pub) return { status: pub.status, body: { error: pub.message } }
1418
1905
  const deliberate = intendedStatus(e)
1419
- if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError ? e.message : STATUS_CODES[deliberate] } }
1906
+ if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError && e.message !== NATIVE_FAILED ? e.message : STATUS_CODES[deliberate] } }
1420
1907
  if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
1421
1908
  return { status: 500, body: { error: 'Internal error' }, cause: e }
1422
1909
  }
@@ -1427,6 +1914,8 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
1427
1914
  // `locale` (§37): the page's language, so the schema messages (`form.*`) and the generic error come from its dictionary
1428
1915
  // (globalThis.__brainDict, filled by the page's chunk) like form() in the browser. It is set only around the synchronous
1429
1916
  // validation, so concurrent requests in other languages never see it.
1917
+ // English text for the server's log: a form or a server function never shows it to a visitor
1918
+ const NATIVE_FAILED = 'Native resource request failed'
1430
1919
  export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeout = 10000, locale }) {
1431
1920
  const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = id.split('/')[1] || ''
1432
1921
  if (key) {
@@ -1449,10 +1938,11 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
1449
1938
  if (Object.keys(errors).length) return again(errors, '')
1450
1939
  let r
1451
1940
  try {
1452
- r = await withTimeout(Promise.resolve(module[fn](ctx, values)), timeout)
1941
+ ctx.signal ||= newSignal()
1942
+ r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(module[fn](ctx, values))), timeout, ctx)
1453
1943
  } catch (e) {
1454
1944
  if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
1455
- if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
1945
+ if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message === NATIVE_FAILED ? generic : e.message)
1456
1946
  const pub = publicError(e)
1457
1947
  if (pub) return again({}, pub.message)
1458
1948
  const deliberate = intendedStatus(e)
@@ -1509,7 +1999,40 @@ function parseCookies(h = '') {
1509
1999
  }
1510
2000
  return out
1511
2001
  }
1512
- const ipOf = req => String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || '?'
2002
+ 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 }
2003
+ /** platform.trustProxy: one BlockList of proxies. Same limits as the backend's parse_proxy (brain-backend/src/platform.rs). */
2004
+ export function trustedProxies(list = []) {
2005
+ const nets = new BlockList()
2006
+ for (const entry of list) {
2007
+ const [address, prefix, extra] = String(entry).split('/')
2008
+ const family = isIP(address)
2009
+ const bits = prefix === undefined ? (family === 4 ? 32 : 128) : Number(prefix)
2010
+ const wide = family === 4 ? bits < 8 : bits < 16 && !(bits === 7 && /^fc00::$/i.test(address))
2011
+ 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`)
2012
+ 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`)
2013
+ nets.addSubnet(address, bits, family === 4 ? 'ipv4' : 'ipv6')
2014
+ }
2015
+ return list.length ? nets : null
2016
+ }
2017
+ /**
2018
+ * The client is the right-most forwarded hop that is not a trusted proxy, and only when the TCP peer is trusted: hops
2019
+ * left of it are client-supplied and never consulted. Same walk as resolve_client (brain-backend/src/edge_http.rs).
2020
+ */
2021
+ export function resolveClient(peer, forwarded, nets) {
2022
+ const socketPeer = plainIp(peer ?? '')
2023
+ const trusted = ip => nets.check(ip, isIP(ip) === 4 ? 'ipv4' : 'ipv6')
2024
+ if (!socketPeer) return '?'
2025
+ if (!nets || !trusted(socketPeer)) return socketPeer
2026
+ let client = socketPeer
2027
+ for (const text of String(forwarded ?? '').split(',').reverse()) {
2028
+ const ip = plainIp(text)
2029
+ if (!ip) return client
2030
+ client = ip
2031
+ if (!trusted(ip)) return ip
2032
+ }
2033
+ return client
2034
+ }
2035
+ export function readBody(req, max) { return body(req, max) }
1513
2036
  function body(req, max) {
1514
2037
  return new Promise((ok, fail) => {
1515
2038
  let n = 0
@@ -1521,7 +2044,10 @@ function body(req, max) {
1521
2044
  else if (n <= max) parts.push(c)
1522
2045
  })
1523
2046
  req.on('end', () => (n > max ? fail(new Error('too large')) : ok(Buffer.concat(parts).toString('utf8'))))
1524
- req.on('error', fail)
2047
+ // a disconnect or the cut above never reaches 'end': the reader settles anyway and nobody is left to answer
2048
+ const aborted = () => fail(Object.assign(new Error('body aborted'), { aborted: true }))
2049
+ req.on('error', aborted)
2050
+ req.on('close', aborted)
1525
2051
  })
1526
2052
  }
1527
2053
  // bind:value / checked are properties; copy them into attributes so the SSR HTML shows them
@@ -1568,19 +2094,27 @@ export function prefersMarkdown(accept) {
1568
2094
  return md > 0 && (md > html || (md === html && mdAt < htmlAt))
1569
2095
  }
1570
2096
  // 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.
2097
+ // F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
1572
2098
  // A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
1573
- function head(page, seo, path) {
2099
+ function head(page, seo, path, search = '') {
1574
2100
  let h = page
2101
+ // `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
2102
+ let identity = ''
2103
+ h = h.replace(/<meta name="brain-query" content="([^"]*)">/, (_, keys) => {
2104
+ const given = new URLSearchParams(search), kept = new URLSearchParams()
2105
+ for (const key of keys.split(/[\s,]+/)) for (const value of key ? given.getAll(key) : []) kept.append(key, value)
2106
+ if (kept.size) identity = esc('?' + kept)
2107
+ return ''
2108
+ })
1575
2109
  if (path) {
1576
2110
  // the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
1577
- const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m))
2111
+ const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m)) + identity
1578
2112
  h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
1579
2113
  h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
1580
2114
  }
1581
2115
  if (!seo) return h
1582
- const meta = (attr, key, val) => {
1583
- const tag = `<meta ${attr}="${esc(key)}" content="${esc(val)}">`
2116
+ const meta = (attr, key, val, escaped) => {
2117
+ const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '&quot;') : esc(val)}">`
1584
2118
  const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
1585
2119
  h = rx.test(h) ? h.replace(rx, () => tag) : h.replace('</head>', () => tag + '</head>')
1586
2120
  }
@@ -1596,6 +2130,17 @@ function head(page, seo, path) {
1596
2130
  else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
1597
2131
  else if (typeof v === 'string') meta('name', k, v)
1598
2132
  }
2133
+ // What the build writes for a prerendered page and can only be known here: the social card of this request's values
2134
+ // (brain-seo social::head, prerender seo_head). The view's own value always wins.
2135
+ const shared = /<title>([\s\S]*?)<\/title>/i.exec(h)?.[1]
2136
+ const origin = /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2137
+ if (typeof seo['og:image'] === 'string' && origin && /^\/(?!\/)/.test(seo['og:image'])) meta('property', 'og:image', encodeURI(origin + seo['og:image']))
2138
+ if (seo.title != null && seo['og:title'] == null) meta('property', 'og:title', shared, true)
2139
+ if (seo.description != null && seo['og:description'] == null) meta('property', 'og:description', seo.description)
2140
+ if (seo['og:image'] != null) {
2141
+ if (seo['og:image:alt'] == null) meta('property', 'og:image:alt', seo['og:title'] ?? shared, seo['og:title'] == null)
2142
+ if (seo['twitter:card'] == null) meta('name', 'twitter:card', 'summary_large_image')
2143
+ }
1599
2144
  // `<meta seo schema="Article">` without `jsonld` (§32): one node with this request's values. It replaces the node
1600
2145
  // the build wrote into the template from the static attributes (same @type), so the page carries exactly one.
1601
2146
  if (typeof seo.schema === 'string' && seo.jsonld == null && /^[A-Za-z]+$/.test(seo.schema)) {
@@ -1637,6 +2182,7 @@ function hook(base, app = '/') {
1637
2182
  )
1638
2183
  }
1639
2184
 
2185
+ const LISTEN_WAIT_MS = 10000
1640
2186
  // `node server.mjs` (directly or under `brain start`); paths compared after symlinks (/var → /private/var on macOS)
1641
2187
  function real(p) {
1642
2188
  try {
@@ -1650,10 +2196,22 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
1650
2196
  const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
1651
2197
  const fd = ENV.BRAIN_LISTEN_FD
1652
2198
  const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
1653
- s.listen(where, () => {
2199
+ s.once('listening', () => {
1654
2200
  process.stdout.write(`brain:ready ${process.pid}\n`) // the supervisor waits for this line (§34)
1655
2201
  console.log(json({ t: new Date().toISOString(), level: 'info', msg: 'listening', ...(fd ? { fd: Number(fd) } : where) }))
1656
2202
  })
2203
+ // A restart may find the previous instance still draining: wait for it, briefly. Two live servers never share a port.
2204
+ const waitUntil = Date.now() + LISTEN_WAIT_MS
2205
+ s.on('error', error => {
2206
+ if (error.code === 'EADDRINUSE' && !fd && !s.listening && Date.now() < waitUntil) return void setTimeout(() => s.listen(where), 100)
2207
+ if (s.listening) throw error
2208
+ const port = where.port
2209
+ 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'
2210
+ const cause = error.code === 'EADDRINUSE' ? `another live process still holds the port after ${LISTEN_WAIT_MS / 1000} s` : error.code
2211
+ console.error(json({ t: new Date().toISOString(), level: 'error', msg: `cannot listen on ${where.host}:${port}: ${cause}`, fix }))
2212
+ process.exit(1)
2213
+ })
2214
+ s.listen(where)
1657
2215
  // SIGTERM/SIGINT: stop accepting, answer what is in flight or arrives on open connections (each then closes), for up
1658
2216
  // to BRAIN_DRAIN_MS (10 s), then cut whatever is left; exit 0 either way
1659
2217
  const drain = () => {