@christianirack/brainllm-runtime 0.8.11 → 0.9.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
@@ -26,25 +26,30 @@ import { installNativeSockets } from './native-sockets.mjs'
26
26
  import { llmClient } from './llm.mjs'
27
27
  import { BlockList, isIP } from 'node:net'
28
28
  import { Readable } from 'node:stream'
29
+ import { pipeline } from 'node:stream/promises'
29
30
  import zlib from 'node:zlib'
30
31
  import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
31
- import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
32
+ import { constants as FS, existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
33
+ import { open } from 'node:fs/promises'
32
34
  import * as Module from 'node:module'
33
35
  import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
34
36
  import { fileURLToPath, pathToFileURL } from 'node:url'
35
37
 
36
38
  const MIME = {
37
39
  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
40
+ '.brain': 'text/html; charset=utf-8', '.jsbrain': 'text/javascript; charset=utf-8', '.cssbrain': 'text/css; charset=utf-8',
38
41
  '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
39
42
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif',
40
43
  '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain; charset=utf-8',
41
44
  '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.map': 'application/json',
42
45
  '.mp4': 'video/mp4', '.webm': 'video/webm', '.pdf': 'application/pdf', '.wasm': 'application/wasm',
46
+ '.m3u8': 'application/vnd.apple.mpegurl', '.m3u': 'application/vnd.apple.mpegurl', '.m4s': 'video/iso.segment',
47
+ '.mpd': 'application/dash+xml', '.ts': 'video/mp2t', '.vtt': 'text/vtt; charset=utf-8', '.mp3': 'audio/mpeg',
43
48
  '.md': 'text/markdown; charset=utf-8', // llms.txt copies of the pages (§33), as brain start's static server sends them
44
49
  }
45
50
  // feeds are .xml files with their own type (SEO, §32)
46
51
  const FEEDS = { 'rss.xml': 'application/rss+xml; charset=utf-8', 'atom.xml': 'application/atom+xml; charset=utf-8' }
47
- const SECURITY = {
52
+ const DEFAULT_SECURITY = {
48
53
  'strict-transport-security': 'max-age=63072000; includeSubDomains',
49
54
  'x-content-type-options': 'nosniff',
50
55
  'referrer-policy': 'strict-origin-when-cross-origin',
@@ -114,8 +119,10 @@ function safeLocation(value) {
114
119
  function linkTo(route, path) {
115
120
  const t = route && route.hrefs
116
121
  if (!t || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path
117
- const m = /^[^?#]*/.exec(path)[0]
118
- const n = t.s[m] || (new RegExp(t.re).test(m) && (m === '/' ? t.p : t.p + m))
122
+ const m = /^[^?#]*/.exec(path)[0], key = m.replace(/\/+$/, '') || '/'
123
+ if (key === t.p || key.startsWith(t.p + '/')) return path
124
+ let n = t.s[key] || (new RegExp(t.re).test(key) && (key === '/' ? t.p : t.p + key))
125
+ if (n && key !== '/' && m.endsWith('/') && !n.endsWith('/')) n += '/'
119
126
  return n ? n + path.slice(m.length) : path
120
127
  }
121
128
  function localizeLinks(route, html) {
@@ -194,13 +201,26 @@ const cacheSearch = url => {
194
201
  return kept.size ? '?' + kept : ''
195
202
  }
196
203
  // Messages this file writes itself, safe to log as the cause; anything else in an error message may hold private data.
197
- const OWN_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Invalid final response status', 'Invalid configured redirect location', 'timeout'])
204
+ const AUTH_UNAVAILABLE_CAUSES = new Set(['the backend did not answer', 'the server is shutting down', '8 session checks are already in flight', 'the backend took more than 2 s'].map(why => `Native authentication is unavailable: ${why}`))
205
+ const BACKEND_UNAVAILABLE_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Native backend is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Native authentication response is unavailable', ...AUTH_UNAVAILABLE_CAUSES])
206
+ const OWN_CAUSES = new Set([...BACKEND_UNAVAILABLE_CAUSES, 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Invalid same-site redirect location', 'Invalid redirect status', 'Invalid final response status', 'Invalid configured redirect location', 'Static file validators are busy; retry shortly', 'Static file validator deadline exceeded; retry shortly', 'Static file changed; retry shortly', 'Static file changed while computing its validator', 'timeout'])
198
207
  const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/
199
208
  const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
200
209
  function bodyEncoding(req, status, headers) {
201
210
  if (status < 200 || status === 204 || status === 206 || status === 304 || headers['content-encoding'] || !COMPRESSIBLE.test(headers['content-type'] || '') || /no-transform/i.test(headers['cache-control'] || '')) return null
202
211
  return acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] || ''
203
212
  }
213
+ // One `bytes=` span of a file of `size` bytes (RFC 9110 §14): { start, end } inclusive, false when it cannot be
214
+ // satisfied (416), or null when the header is not one well-formed span (several, another unit, reversed): the whole
215
+ // file is sent then, which the RFC allows.
216
+ function byteRange(header, size) {
217
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
218
+ if (!m || (m[1] === '' && m[2] === '')) return null
219
+ if (m[1] === '') return Number(m[2]) > 0 && size > 0 ? { start: Math.max(0, size - Number(m[2])), end: size - 1 } : false
220
+ const start = Number(m[1])
221
+ if (m[2] !== '' && Number(m[2]) < start) return null
222
+ return start < size ? { start, end: m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1) } : false
223
+ }
204
224
  const encodeBody = (enc, buf) => enc === 'zstd' ? zlib.zstdCompressSync(buf) : enc === 'br' ? zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) : zlib.gzipSync(buf)
205
225
  const encodeStream = enc => enc === 'zstd' ? zlib.createZstdCompress() : enc === 'br' ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }) : zlib.createGzip()
206
226
 
@@ -220,11 +240,22 @@ function googleSignInPage(page) {
220
240
  for (const item of tag.slice(5, -1).matchAll(/([^\s=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
221
241
  const key = item[1].toLowerCase(); if (!attrs.has(key)) attrs.set(key, item[2] ?? item[3] ?? item[4] ?? '')
222
242
  }
223
- if (attrs.get('name')?.toLowerCase() === 'brain-google-signin' && attrs.get('content') === '1') return true
243
+ // Google's and Apple's sign-in popups both answer through window.opener.
244
+ if (['brain-google-signin', 'brain-apple-signin'].includes(attrs.get('name')?.toLowerCase()) && attrs.get('content') === '1') return true
224
245
  }
225
246
  return false
226
247
  }
227
248
 
249
+ // One-use provider cookies (Google nonce, Apple nonce, LinkedIn state): relayed to the backend and cleared at logout.
250
+ const PROVIDER_COOKIES = ['__Host-brain-google', '__Host-brain-apple', '__Host-brain-linkedin']
251
+ // The native backend owns state, nonce and provider configuration. Only this fixed protocol destination is external.
252
+ function providerStartRedirect(method, path, status, location) {
253
+ if (!['GET', 'HEAD'].includes(method) || path !== '/_brain/auth/linkedin/start' || status !== 303 || typeof location !== 'string' || /[\\\x00-\x20\x7f]/.test(location)) return false
254
+ try {
255
+ const target = new URL(location)
256
+ return target.origin === 'https://www.linkedin.com' && target.pathname === '/oauth/v2/authorization' && !target.username && !target.password && !target.hash
257
+ } catch { return false }
258
+ }
228
259
  // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
229
260
  // ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
230
261
  function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
@@ -327,7 +358,15 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
327
358
  }
328
359
  const safeUser = user => {
329
360
  if (!user || typeof user.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id) || typeof user.email !== 'string' || user.email.length > 254 || typeof user.role !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(user.role) || ['public','owner'].includes(user.role) || typeof user.verified !== 'boolean') throw new HttpError(503,'Native authentication response is unavailable')
330
- return Object.freeze({id:user.id,email:user.email,role:user.role,verified:user.verified})
361
+ const clean = {id:user.id,email:user.email,role:user.role,verified:user.verified}
362
+ if (typeof user.name === 'string' && user.name.trim() && user.name.length <= 400 && [...user.name].length <= 200 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.name)) clean.name = user.name.trim()
363
+ if (typeof user.picture === 'string' && Buffer.byteLength(user.picture) <= 2048 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.picture)) {
364
+ try {
365
+ const url = new URL(user.picture)
366
+ if (url.protocol === 'https:' && url.hostname && !url.username && !url.password) clean.picture = user.picture
367
+ } catch {}
368
+ }
369
+ return Object.freeze(clean)
331
370
  }
332
371
  const unavailable = (why = 'the backend did not answer') => new HttpError(503, `Native authentication is unavailable: ${why}`)
333
372
  const call = (incoming, response, path, method, rid, origin) => {
@@ -365,7 +404,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
365
404
  for (const value of backend.headers['set-cookie'] || []) {
366
405
  if (value.length > 8192 || /[\r\n]/.test(value)) return finish(true)
367
406
  const name = value.split('=', 1)[0]
368
- if (names.has(name) || name === '__Host-brain-google') cleared.push(value)
407
+ if (names.has(name) || PROVIDER_COOKIES.includes(name)) cleared.push(value)
369
408
  }
370
409
  finish(false, { 'set-cookie': cleared, 'clear-site-data': '"storage"' })
371
410
  })
@@ -439,14 +478,14 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
439
478
  const selected = []
440
479
  for (const part of String(req.headers.cookie || '').split(';')) {
441
480
  const name = part.trim().split('=')[0]
442
- if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
481
+ if (['__Host-brain','__Host-brain-access','__Host-brain-refresh',...PROVIDER_COOKIES].includes(name)) selected.push(part.trim())
443
482
  }
444
483
  if (selected.length) headers.cookie = selected.join('; ')
445
484
  const client = forwardedFor(req)
446
485
  if (client) headers['x-forwarded-for'] = client
447
486
  return headers
448
487
  }
449
- const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
488
+ const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google|-apple|-linkedin)?)=/.test(value) && value.length <= 8192).slice(0,8)
450
489
  // /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
451
490
  function fileStream(req, res, url, rid) {
452
491
  if (stopped || streams >= 32) throw bridgeBusy('Backend stream capacity is unavailable')
@@ -526,7 +565,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
526
565
  const cookies = nativeCookies(backend.headers['set-cookie'])
527
566
  if (cookies.length) safe['set-cookie'] = cookies
528
567
  if (backend.statusCode >= 500 && !res.$capture) res.$brainCause = 'answered by the native backend'
529
- try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
568
+ try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe,providerStartRedirect(req.method,url.pathname,backend.statusCode,safe.location)); resolve() } catch (e) { reject(e) }
530
569
  }
531
570
  const cancel = () => { outgoing?.destroy(); finish(true) }
532
571
  const data = chunk => {
@@ -985,7 +1024,7 @@ export function createServer(o = {}) {
985
1024
  // a thrown plain object, string or undefined has no stack: say what kind of value it was (never the value itself)
986
1025
  if (!(e instanceof Error)) out.thrown = e === null ? 'null' : Array.isArray(e) ? 'array' : typeof e
987
1026
  if (e && typeof e === 'object' && (e.temporary === true || e.status === 429 || e.status === 503)) out.temporary = true
988
- if (e instanceof HttpError && (OWN_CAUSES.has(e.message) || e.message.startsWith('Native authentication is unavailable:'))) out.cause = e.message
1027
+ if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
989
1028
  if (e && e.code === 'ERR_MODULE_NOT_FOUND') out.fix = 'this process runs an older build whose files are gone; restart it after brain build'
990
1029
  if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
991
1030
  if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
@@ -996,6 +1035,11 @@ export function createServer(o = {}) {
996
1035
  return out
997
1036
  }
998
1037
  const mf = readManifest(dist)
1038
+ // The build policy controls attribution independently of CSP and other security headers.
1039
+ let compilerBranding=mf.compilerBranding!==false
1040
+ try {const path=join(dist,'brain-build.json'),meta=lstatSync(path);if(meta.isFile()&&!meta.isSymbolicLink()&&meta.size<=16384&&JSON.parse(readFileSync(path,'utf8')).compilerBranding===false)compilerBranding=false}catch{}
1041
+ const SECURITY={...DEFAULT_SECURITY}
1042
+ if(!compilerBranding)delete SECURITY['x-generated-by']
999
1043
  // brain.json `server` (§11.1), written into the manifest by the build; createServer options and env still win
1000
1044
  const SL = mf.server || {}
1001
1045
  const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? SL.timeoutMs ?? 10000)
@@ -1027,6 +1071,48 @@ export function createServer(o = {}) {
1027
1071
  }
1028
1072
  return resolveClient(req.socket.remoteAddress, forwarded, proxies)
1029
1073
  }
1074
+ // platform.access (docs/backend/access.md): /api, /_brain and /s answer this site only. Refused sources are counted
1075
+ // per client address (at most 512, the oldest dropped) for `brain access sources`, and logged at most 16 a minute.
1076
+ const access = accessPolicy(o.access ?? mf.access ?? {}, mf.site || env.PUBLIC_SITE_URL)
1077
+ const refusedSources = new Map()
1078
+ const budgetKey = `edge ${++servers} `
1079
+ let accessWindow = 0, accessLogs = 0, inFlight = 0, limitedWindow = 0, limited = 0, limitedLogs = 0
1080
+ // the dynamic budget (accessBudget) and the in-flight cap: past either the answer is an immediate 429 or 503 with
1081
+ // Retry-After, no queue, and one log line per minute sums what was turned away
1082
+ const overBudget = (req, res, path, client) => {
1083
+ const wait = access ? accessBudget(path, client, access, budgetKey) : 0
1084
+ const busy = !wait && access.concurrency !== null && !ACCESS_EXEMPT.test(path) && inFlight >= access.concurrency
1085
+ if (!wait && !busy) {
1086
+ if (access.concurrency !== null) { inFlight++; res.once('close', () => inFlight--) }
1087
+ return false
1088
+ }
1089
+ const minute = Math.floor(Date.now() / 60000)
1090
+ if (limitedWindow !== minute) {
1091
+ if (limited) log('warn', 'requests limited', { count: limited, minute: new Date(limitedWindow * 60000).toISOString(), fix: 'raise platform.access.perMinute or concurrency if real visitors are among them' })
1092
+ limitedWindow = minute; limited = 0; limitedLogs = 0
1093
+ }
1094
+ limited++
1095
+ if (limitedLogs++ < 4) log('warn', 'request limited', { ip: client, path: String(path).slice(0, 256), reason: busy ? 'concurrency' : 'perMinute', userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'].slice(0, 256) : null })
1096
+ send(res, busy ? 503 : 429, busy ? 'Busy, try again shortly' : 'Too many requests', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'retry-after': String(busy ? 2 : wait) })
1097
+ return true
1098
+ }
1099
+ const accessRefused = (req, rid, path, client, reason) => {
1100
+ const bound = v => typeof v === 'string' ? v.slice(0, 256) : null
1101
+ const now = new Date().toISOString()
1102
+ let entry = refusedSources.get(client)
1103
+ if (entry) refusedSources.delete(client)
1104
+ else {
1105
+ if (refusedSources.size >= 512) refusedSources.delete(refusedSources.keys().next().value)
1106
+ entry = { ip: client, count: 0, first: now, reasons: {} }
1107
+ }
1108
+ entry.count++; entry.last = now; entry.reasons[reason] = (entry.reasons[reason] || 0) + 1
1109
+ Object.assign(entry, { path: bound(path), origin: bound(req.headers.origin), referer: bound(req.headers.referer), host: bound(req.headers['x-forwarded-host'] || req.headers.host), userAgent: bound(req.headers['user-agent']), country: bound(req.headers['cf-ipcountry']) })
1110
+ refusedSources.set(client, entry)
1111
+ const minute = Math.floor(Date.now() / 60000)
1112
+ if (accessWindow !== minute) { accessWindow = minute; accessLogs = 0 }
1113
+ if (accessLogs++ >= 16) return
1114
+ log('warn', 'access refused', { code: 'B0752', reason, rid, ip: client, path: entry.path, origin: entry.origin, referer: entry.referer, secFetchSite: bound(req.headers['sec-fetch-site']), host: entry.host, userAgent: entry.userAgent, fix: 'the native API answers this site only; a page of the site, an API key, or an address in platform.access.ips passes. Run brain explain B0752' })
1115
+ }
1030
1116
  // server/routes/* of the native backend, listed by the build: only these exact shapes cross the bridge
1031
1117
  const nativeRoutes = (Array.isArray(mf.nativeRoutes) ? mf.nativeRoutes : []).map(route => {
1032
1118
  if (typeof route?.path !== 'string' || !Array.isArray(route.headers)) throw Error('Manifest nativeRoutes must list path and headers')
@@ -1127,17 +1213,22 @@ export function createServer(o = {}) {
1127
1213
  // HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
1128
1214
  // taken before the per-response nonce is injected, so it is stable
1129
1215
  const html = (res, status, page, headers = {}, etag) => {
1216
+ // A 304 updates the cached document's policy too; keep it identical to its 200 without minting a CSP nonce.
1217
+ headers = { 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers }
1130
1218
  if (etag && status === 200) {
1131
1219
  headers = { etag, ...headers }
1132
1220
  const inm = res.req.headers['if-none-match']
1133
1221
  if (inm && inm.split(',').some(t => t.trim().replace(/^W\//, '') === etag.replace(/^W\//, ''))) return send(res, 304, '', headers)
1134
1222
  }
1223
+ // RFC 9111 section 4.3.5 lets HEAD update cached GET headers without replacing its body.
1224
+ // As with 304, omit a new body-bound policy so the cached script nonce remains authorized.
1225
+ if (res.req.method === 'HEAD') return send(res, status, '', { 'content-type': MIME['.html'], ...headers })
1135
1226
  const nonce = randomBytes(16).toString('base64')
1136
1227
  // behavior analytics (docs/backend/analytics.md): with a native backend every page loads its collector as an async
1137
1228
  // module served by that backend, outside the build and the route budgets; BRAIN_ANALYTICS=off skips the tag
1138
1229
  if (analyticsTag && !page.includes(analyticsTag)) page = page.includes('</head>') ? page.replace('</head>', analyticsTag + '</head>') : page + analyticsTag
1139
1230
  const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
1140
- send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
1231
+ send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), ...headers })
1141
1232
  }
1142
1233
  const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
1143
1234
  // `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
@@ -1150,7 +1241,7 @@ export function createServer(o = {}) {
1150
1241
  }
1151
1242
  // `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
1152
1243
  const reservedStatus = pathname => {
1153
- const m = /^\/(403|404|410|5\d\d)(?:\.html)?$/.exec(pathname)
1244
+ const m = /^\/(403|404|410|5\d\d)(?:\.(?:html|brain))?$/.exec(pathname)
1154
1245
  if (!m) return 0
1155
1246
  const n = +m[1]
1156
1247
  return file(`${n}.html`) || (mf.errors && mf.errors[n]) || (n === 403 && mf.forbidden) || (n === 404 && mf.notFound) ? n : 0
@@ -1301,16 +1392,30 @@ export function createServer(o = {}) {
1301
1392
  }
1302
1393
  const anonymous = req => (req.method === 'GET' || req.method === 'HEAD') && !req.headers.authorization && !req.headers.cookie
1303
1394
 
1395
+ const preloadHints = new WeakMap()
1304
1396
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
1305
1397
  // the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
1306
1398
  // 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.
1307
1399
  if (route.links && route.links.length && !res.$capture && !res.$hinted) {
1308
1400
  res.$hinted = true
1309
- const link = route.links.join(', ')
1310
- if (link.length <= 2048 && !res.headersSent) res.setHeader('link', link)
1311
- if (EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1401
+ let selected = preloadHints.get(route)
1402
+ if (!selected) {
1403
+ selected = []
1404
+ let bytes = 0
1405
+ // Preserve graph order within a priority; render-blocking styles and fonts precede script hints.
1406
+ const priority = value => /(?:^|;)\s*as=(?:style|font)(?:;|$)/.test(value) ? 0 : 1
1407
+ for (const value of [...route.links].sort((a, b) => priority(a) - priority(b))) {
1408
+ const size = Buffer.byteLength(value) + (selected.length ? 2 : 0)
1409
+ if (bytes + size > 2048) continue
1410
+ selected.push(value); bytes += size
1411
+ }
1412
+ preloadHints.set(route, selected)
1413
+ if (selected.length < route.links.length) log('warn', 'preload hints trimmed to 2048 bytes; fix: reduce the route preload graph', { route: route.path, kept: selected.length, dropped: route.links.length - selected.length })
1414
+ }
1415
+ if (selected.length && !res.headersSent) res.setHeader('link', selected.join(', '))
1416
+ if (selected.length && EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1312
1417
  try {
1313
- res.writeEarlyHints({ link: route.links })
1418
+ res.writeEarlyHints({ link: selected })
1314
1419
  } catch {}
1315
1420
  }
1316
1421
  }
@@ -1389,7 +1494,7 @@ export function createServer(o = {}) {
1389
1494
  // a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
1390
1495
  if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
1391
1496
  // with seo.trailingSlash "ignore" both forms answer: the canonical takes the form the sitemap lists (no slash)
1392
- let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search)
1497
+ let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search, route, BASE.slice(0, -1))
1393
1498
  // route params for route() in the browser (the client has no route table)
1394
1499
  if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
1395
1500
  if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
@@ -1427,9 +1532,18 @@ export function createServer(o = {}) {
1427
1532
  const seoF = sm.seo || (await load(route.view)).default.seo
1428
1533
  if (route.i18n) await load(route.i18n)
1429
1534
  const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
1535
+ globalThis.__brainLocale = route.locale || null
1536
+ let seo0 = null
1537
+ try {
1538
+ seo0 = typeof seoF === 'function' ? seoF({}) : null
1539
+ } finally {
1540
+ globalThis.__brainLocale = null
1541
+ }
1542
+ const initialHead = head(top, seo0, url.pathname, url.search, route, BASE.slice(0, -1))
1430
1543
  const enc = COMPRESS && req.method !== 'HEAD' ? acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] : null
1431
1544
  res.writeHead(200, {
1432
- ...SECURITY, 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache',
1545
+ ...SECURITY, 'content-type': MIME['.html'], ...(req.method === 'HEAD' ? {} : { 'content-security-policy': withNonce(mf.csp || CSP0, nonce) }), 'cache-control': 'no-cache',
1546
+ ...(googleSignInPage(initialHead) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}),
1433
1547
  ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...extra, ...ctx.session.headers(),
1434
1548
  ...(COMPRESS ? { vary: varyEncoding(extra.vary) } : {}), ...(enc ? { 'content-encoding': enc } : {}),
1435
1549
  })
@@ -1437,15 +1551,8 @@ export function createServer(o = {}) {
1437
1551
  const zs = enc ? encodeStream(enc) : null
1438
1552
  if (zs) zs.pipe(res)
1439
1553
  const write = s => (zs ? (zs.write(s), zs.flush()) : res.write(s))
1440
- globalThis.__brainLocale = route.locale || null
1441
- let seo0 = null
1442
- try {
1443
- seo0 = typeof seoF === 'function' ? seoF({}) : null
1444
- } finally {
1445
- globalThis.__brainLocale = null
1446
- }
1447
1554
  const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
1448
- write(n(head(top, seo0, url.pathname, url.search)))
1555
+ write(n(initialHead))
1449
1556
  // QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
1450
1557
  // between two marker comments; the last chunk starts with a script that removes them and what is between
1451
1558
  let clear = ''
@@ -1723,7 +1830,9 @@ export function createServer(o = {}) {
1723
1830
  }
1724
1831
  // §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
1725
1832
  // /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
1726
- const SET_HEAD = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1833
+ const XML_SIGNATURE = compilerBranding ? '<!-- by BRAINLLM https://chrisifm.github.io/BRAINLLM/ -->' : ''
1834
+ const XML_HEADER = `<?xml version="1.0" encoding="UTF-8"?>\n${XML_SIGNATURE}\n`
1835
+ const SET_HEAD = XML_HEADER + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1727
1836
  const SET_FOOT = '</urlset>\n'
1728
1837
  const bytes = t => Buffer.byteLength(t)
1729
1838
  function pages(urls) {
@@ -1741,7 +1850,9 @@ export function createServer(o = {}) {
1741
1850
  async function serveSitemap(req, res, url) {
1742
1851
  const f = file('/sitemap.xml')
1743
1852
  if (!f) return false
1744
- const text = readFileSync(f, 'utf8')
1853
+ const original = readFileSync(f, 'utf8')
1854
+ const declaration = original.startsWith('<?xml') ? original.indexOf('?>') + 2 : 0
1855
+ const text = !XML_SIGNATURE || original.includes(XML_SIGNATURE) ? original : declaration > 1 ? original.slice(0, declaration) + '\n' + XML_SIGNATURE + original.slice(declaration) : XML_SIGNATURE + '\n' + original
1745
1856
  const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1746
1857
  if (!origin) return false
1747
1858
  const index = text.includes('<sitemapindex')
@@ -1761,13 +1872,47 @@ export function createServer(o = {}) {
1761
1872
  index
1762
1873
  ? text.replace('</sitemapindex>', `${kids}</sitemapindex>`)
1763
1874
  : split
1764
- ? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
1875
+ ? `${XML_HEADER}<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
1765
1876
  : text.replace('</urlset>', sets[0] + '</urlset>'),
1766
1877
  )
1767
1878
  return true
1768
1879
  }
1769
1880
 
1770
1881
  // ---------- static ----------
1882
+ // Content validators use bounded buffers and a bounded metadata cache; a full media file never enters the heap.
1883
+ const fileTags = new Map(), hashingFiles = new Map()
1884
+ async function fileTag(handle, stat) {
1885
+ const key = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
1886
+ if (fileTags.has(key)) return fileTags.get(key)
1887
+ if (hashingFiles.has(key)) return hashingFiles.get(key)
1888
+ if (hashingFiles.size >= 8) throw new HttpError(503, 'Static file validators are busy; retry shortly')
1889
+ const task = (async () => {
1890
+ const hash = createHash('sha256'), buf = Buffer.alloc(65536), until = performance.now() + 30000
1891
+ let offset = 0
1892
+ while (offset < stat.size) {
1893
+ if (performance.now() > until) throw new HttpError(503, 'Static file validator deadline exceeded; retry shortly')
1894
+ const { bytesRead } = await handle.read(buf, 0, Math.min(buf.length, stat.size - offset), offset)
1895
+ if (!bytesRead) throw new HttpError(503, 'Static file changed while computing its validator')
1896
+ hash.update(buf.subarray(0, bytesRead)); offset += bytesRead
1897
+ }
1898
+ const after = await handle.stat()
1899
+ if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) throw new HttpError(503, 'Static file changed; retry shortly')
1900
+ const etag = '"' + hash.digest('base64url') + '"'
1901
+ fileTags.set(key, etag)
1902
+ if (fileTags.size > 256) fileTags.delete(fileTags.keys().next().value)
1903
+ return etag
1904
+ })()
1905
+ hashingFiles.set(key, task)
1906
+ try { return await task } finally { hashingFiles.delete(key) }
1907
+ }
1908
+ async function streamFile(req, res, handle, status, headers, span) {
1909
+ if (req.method === 'HEAD') return send(res, status, '', headers)
1910
+ res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
1911
+ // The descriptor stays pinned from stat and hashing through transmission, including atomic deployments.
1912
+ const length = Number(headers['content-length'])
1913
+ if (!length) return res.end()
1914
+ await pipeline(handle.createReadStream({ start: 0, end: length - 1, ...span, autoClose: false }), res)
1915
+ }
1771
1916
  // Never served (brain-pack private.rs holds the same list for the Rust handler and every generated host config).
1772
1917
  const PRIVATE_PREFIXES = ['_server', '_brain', 'server.mjs']
1773
1918
  const PRIVATE_FILES = ['_headers', '_redirects']
@@ -1795,7 +1940,7 @@ export function createServer(o = {}) {
1795
1940
  // site configured) or with a per-request pattern gets no header, so this never invents a URL.
1796
1941
  function twinCanonical(path) {
1797
1942
  const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
1798
- const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
1943
+ const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html') || file(stem ? stem + '.brain' : 'index.brain') || file(stem + '/index.brain')
1799
1944
  if (!page) return {}
1800
1945
  const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
1801
1946
  const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&amp;', '&')
@@ -1804,21 +1949,21 @@ export function createServer(o = {}) {
1804
1949
  // What exists whatever the routes say: generated sitemaps, built files and the icon browsers ask for by habit.
1805
1950
  async function serveFixed(req, res, url) {
1806
1951
  if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return true
1807
- if (serveStatic(req, res, url)) return true
1952
+ if (await serveStatic(req, res, url)) return true
1808
1953
  if (url.pathname !== '/favicon.ico' || !file('favicon.svg')) return false
1809
1954
  send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
1810
1955
  return true
1811
1956
  }
1812
1957
 
1813
- function serveStatic(req, res, url) {
1958
+ async function serveStatic(req, res, url) {
1814
1959
  const p = publicStaticPath(url.pathname)
1815
1960
  if (p === null) return false
1816
- const candidates = p.endsWith('/') ? [p + 'index.html', ...(p.length > 1 ? [p.slice(0, -1) + '.html'] : [])] : [p, p + '.html', p + '/index.html']
1961
+ const candidates = p.endsWith('/') ? [p + 'index.html', p + 'index.brain', ...(p.length > 1 ? [p.slice(0, -1) + '.html', p.slice(0, -1) + '.brain'] : [])] : [p, p + '.html', p + '/index.html', p + '.brain', p + '/index.brain']
1817
1962
  for (const c of candidates) {
1818
1963
  const f = file(c)
1819
1964
  if (!f) continue
1820
1965
  const ext = extname(f), type = FEEDS[basename(f)] || MIME[ext] || 'application/octet-stream'
1821
- if (ext === '.html') {
1966
+ if (ext === '.html' || ext === '.brain') {
1822
1967
  // §33: the page's Markdown twin (`/pricing` → pricing.md) when the client prefers text/markdown
1823
1968
  if (serveTwin(req, res, url)) return true
1824
1969
  const md = file(mdPath(p))
@@ -1830,21 +1975,39 @@ export function createServer(o = {}) {
1830
1975
  ...(ext === '.md' ? twinCanonical(p) : {}),
1831
1976
  'content-type': type,
1832
1977
  // the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
1833
- 'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
1978
+ 'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' || /\.(m3u8?|mpd)$/i.test(p) ? 'no-cache' : 'public, max-age=3600',
1834
1979
  vary: 'accept-encoding',
1835
1980
  }
1836
1981
  // the smallest precompressed sibling the client accepts (the build keeps only those smaller than the file)
1837
1982
  let best = null
1838
- for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
1983
+ for (const enc of req.headers.range ? [] : acceptedEncodings(req.headers['accept-encoding'])) {
1839
1984
  const encoded = file(c + (enc === 'br' ? '.br' : enc === 'zstd' ? '.zst' : '.gz'))
1840
1985
  const size = encoded ? statSync(encoded).size : Infinity
1841
1986
  if (encoded && (!best || size < best.size)) best = { enc, encoded, size }
1842
1987
  }
1843
- if (best) {
1844
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(best.encoded), { ...headers, 'content-encoding': best.enc })
1845
- return true
1988
+ const selected = best ? best.encoded : f
1989
+ const handle = await open(selected, FS.O_RDONLY | (FS.O_NOFOLLOW || 0) | (FS.O_NONBLOCK || 0))
1990
+ try {
1991
+ const stat = await handle.stat()
1992
+ if (!stat.isFile()) throw new Error('Static output must be a regular file')
1993
+ headers.etag = await fileTag(handle, stat)
1994
+ if (res.destroyed) return true
1995
+ if (!best) headers['accept-ranges'] = 'bytes'
1996
+ const inm = req.headers['if-none-match']
1997
+ if (inm && inm.split(',').some(t => t.trim() === '*' || t.trim().replace(/^W\//, '') === headers.etag)) return send(res, 304, '', headers), true
1998
+ const span = req.headers.range && (!req.headers['if-range'] || req.headers['if-range'] === headers.etag) ? byteRange(req.headers.range, stat.size) : null
1999
+ if (span === false) return send(res, 416, '', { ...headers, 'content-range': `bytes */${stat.size}` }), true
2000
+ if (span) {
2001
+ headers['content-range'] = `bytes ${span.start}-${span.end}/${stat.size}`
2002
+ headers['content-length'] = String(span.end - span.start + 1)
2003
+ } else {
2004
+ headers['content-length'] = String(stat.size)
2005
+ if (best) headers['content-encoding'] = best.enc
2006
+ }
2007
+ await streamFile(req, res, handle, span ? 206 : 200, headers, span)
2008
+ } finally {
2009
+ await handle.close()
1846
2010
  }
1847
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), headers)
1848
2011
  return true
1849
2012
  }
1850
2013
  return false
@@ -1936,6 +2099,11 @@ export function createServer(o = {}) {
1936
2099
  let full
1937
2100
  try { full = new URL(req.url, 'http://' + host) } catch { return send(res, 400, 'Bad Request') }
1938
2101
  // /_health stays at the root: the supervisor calls it there whatever the base
2102
+ // refused API sources, for an operator on this machine only (a loopback socket and no forwarded hop)
2103
+ if (full.pathname === '/_access/sources' && req.method === 'GET') {
2104
+ if (!LOOPBACK(plainIp(req.socket.remoteAddress ?? '') || '') || req.headers['x-forwarded-for']) return notFound(req, res)
2105
+ return send(res, 200, json({ sources: [...refusedSources.values()].sort((a, b) => b.count - a.count) }), { 'content-type': MIME['.json'], 'cache-control': 'no-store' })
2106
+ }
1939
2107
  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' })
1940
2108
  let url = full
1941
2109
  if (BASE !== '/') {
@@ -1950,9 +2118,20 @@ export function createServer(o = {}) {
1950
2118
  const probe = brainProbe(url.pathname)
1951
2119
  if (probe) return await answerProbe(req, res, new URL(probe, url), rid)
1952
2120
  if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
1953
- if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
2121
+ if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) {
2122
+ const client = ipOf(req), refused = accessRefusal(req, url.pathname, client, access)
2123
+ if (refused) {
2124
+ accessRefused(req, rid, url.pathname, client, refused)
2125
+ return send(res, 403, json({ error: 'This API answers its own site only', code: 'B0752' }), { 'content-type': MIME['.json'], 'cache-control': 'no-store', 'x-brain-code': 'B0752' })
2126
+ }
2127
+ if (overBudget(req, res, url.pathname, client)) return
2128
+ return await native.proxy(req,res,url,rid,send)
2129
+ }
1954
2130
  const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
1955
- if (declaredRoute) return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
2131
+ if (declaredRoute) {
2132
+ if (overBudget(req, res, url.pathname, ipOf(req))) return
2133
+ return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
2134
+ }
1956
2135
  // redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
1957
2136
  // same-site targets get the base, the query is kept when the target has none; slash policy and host in the same hop
1958
2137
  const to = (req.method === 'GET' || req.method === 'HEAD') && canonicalOf(req, url)
@@ -1964,7 +2143,7 @@ export function createServer(o = {}) {
1964
2143
  const post = req.method === 'POST'
1965
2144
  if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
1966
2145
  // a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
1967
- const reserved = post ? 0 : reservedStatus(url.pathname)
2146
+ const reserved = post ? 0 : reservedStatus(publicStaticPath(url.pathname) ?? url.pathname)
1968
2147
  if (reserved === 403) return forbidden(req, res, url, rid, '')
1969
2148
  if (reserved) {
1970
2149
  // Anyone can open it, so the note about it is bounded and is not an error.
@@ -1977,14 +2156,16 @@ export function createServer(o = {}) {
1977
2156
  for (const route of routes) {
1978
2157
  const m = route.rx.exec(url.pathname)
1979
2158
  if (!m) continue
2159
+ const protectedRoute = route.guards?.length || route.middleware?.length
1980
2160
  // a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
1981
- if (!post && route.forms && serveStatic(req, res, url)) return
2161
+ if (!post && !protectedRoute && route.forms && (await serveStatic(req, res, url))) return
1982
2162
  // a parameter never hides a real file: `/[code]` must not swallow /favicon.svg, /sitemap.xml or a prerendered /login
1983
- if (!post && m.groups && (await serveFixed(req, res, url))) return
1984
- if (!post && serveTwin(req, res, url)) return
2163
+ if (!post && !protectedRoute && m.groups && (await serveFixed(req, res, url))) return
2164
+ if (!post && !protectedRoute && serveTwin(req, res, url)) return
1985
2165
  const paged = post ? null : pageParameter(route, url)
1986
2166
  if (paged === 404) return notFound(req, res)
1987
2167
  if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
2168
+ if (overBudget(req, res, url.pathname, ipOf(req))) return
1988
2169
  return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
1989
2170
  }
1990
2171
  if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
@@ -1994,7 +2175,7 @@ export function createServer(o = {}) {
1994
2175
  } catch (e) {
1995
2176
  const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
1996
2177
  if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
1997
- else if (e instanceof HttpError && OWN_CAUSES.has(e.message)) log('warn', 'backend unavailable', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: e.status, cause: e.message, ...(CODE_SHAPE.test(e.transport || '') ? { transport: e.transport } : {}) })
2178
+ else if (e instanceof HttpError && e.status >= 500 && BACKEND_UNAVAILABLE_CAUSES.has(e.message)) log('warn', 'backend unavailable', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: e.status, cause: e.message, ...(CODE_SHAPE.test(e.transport || '') ? { transport: e.transport } : {}) })
1998
2179
  else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
1999
2180
  try {
2000
2181
  if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
@@ -2203,6 +2384,7 @@ export async function runChain({ guards = [], middleware = [], ctx }) {
2203
2384
  // key past HITS_KEY_MAX characters is stored as its digest. The map stays ordered by window start: a new window is
2204
2385
  // re-inserted at the end, so the expired entries are always the first ones.
2205
2386
  const HITS = new Map() // key → [windowStart, count]
2387
+ let servers = 0 // each createServer keeps its own dynamic budget (accessBudget) in HITS
2206
2388
  export const HITS_MAX = 50000, HITS_KEY_MAX = 256
2207
2389
  export const rateLimitKeys = () => HITS.size
2208
2390
  const WINDOW = 60000
@@ -2424,6 +2606,94 @@ export function trustedProxies(list = []) {
2424
2606
  * The client is the right-most forwarded hop that is not a trusted proxy, and only when the TCP peer is trusted: hops
2425
2607
  * left of it are client-supplied and never consulted. Same walk as resolve_client (brain-backend/src/edge_http.rs).
2426
2608
  */
2609
+ /**
2610
+ * platform.access (docs/backend/access.md): who may reach the dynamic answers and how much each client may take.
2611
+ * `mode` "open" (default) lets any client in, scripts and headless browsers included; "own-host" refuses /api, /_brain
2612
+ * and /s to anything without evidence of this site. Nothing is limited unless the app sets `perMinute` (dynamic
2613
+ * requests per client and minute) or `concurrency` (dynamic requests in flight for the whole process). Normalizes `{ mode, perMinute, concurrency, origins, hosts, ips,
2614
+ * local }`; `site` is the site origin (PUBLIC_SITE_URL), whose host is always one of the hosts.
2615
+ * Same limits as the backend's parse_access (brain-backend/src/platform.rs).
2616
+ */
2617
+ export function accessPolicy(raw = {}, site) {
2618
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw Error('platform.access must be an object')
2619
+ for (const key of Object.keys(raw)) if (!['mode', 'perMinute', 'concurrency', 'origins', 'hosts', 'ips', 'local'].includes(key)) throw Error(`platform.access.${key} is not a setting; use mode, perMinute, concurrency, origins, hosts, ips or local`)
2620
+ const list = (key, max) => {
2621
+ const value = raw[key] ?? []
2622
+ if (!Array.isArray(value) || value.length > max || value.some(v => typeof v !== 'string' || !v || v.length > 256)) throw Error(`platform.access.${key} must be an array of at most ${max} strings`)
2623
+ return value
2624
+ }
2625
+ const origins = new Set()
2626
+ for (const origin of list('origins', 32)) {
2627
+ let parsed
2628
+ try { parsed = new URL(origin) } catch { throw Error(`platform.access.origins entry \`${origin}\` must be an origin such as https://admin.example.com`) }
2629
+ if (parsed.origin !== origin || !/^https?:$/.test(parsed.protocol)) throw Error(`platform.access.origins entry \`${origin}\` must be exactly scheme://host[:port], without a path or a trailing slash`)
2630
+ origins.add(origin)
2631
+ }
2632
+ const hosts = new Set()
2633
+ for (const host of list('hosts', 32)) {
2634
+ if (!/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/.test(host)) throw Error(`platform.access.hosts entry \`${host}\` must be a lowercase host name without scheme, port or wildcard`)
2635
+ hosts.add(host)
2636
+ }
2637
+ try { if (site) hosts.add(new URL(site).hostname) } catch {}
2638
+ const ips = list('ips', 64)
2639
+ const nets = ips.length ? trustedProxies(ips) : null
2640
+ if (raw.local !== undefined && typeof raw.local !== 'boolean') throw Error('platform.access.local must be true or false')
2641
+ const mode = raw.mode ?? 'open'
2642
+ if (mode !== 'open' && mode !== 'own-host') throw Error('platform.access.mode must be "open" or "own-host"')
2643
+ const count = (key, max) => {
2644
+ const value = raw[key] ?? null
2645
+ if (value !== null && (!Number.isInteger(value) || value < 1 || value > max)) throw Error(`platform.access.${key} must be a whole number from 1 to ${max}`)
2646
+ return value
2647
+ }
2648
+ return Object.freeze({ mode, perMinute: count('perMinute', 100000), concurrency: count('concurrency', 10000), origins, hosts, nets, local: raw.local !== false })
2649
+ }
2650
+ // Paths a third party reaches on purpose: links, the analytics script, signed inbound webhooks and Apple's
2651
+ // server-to-server notifications. Each carries its own check.
2652
+ const ACCESS_EXEMPT = /^\/(?:s\/|_brain\/analytics\.js$|_brain\/webhooks\/in\/[a-z][a-z0-9_]{0,47}$|_brain\/auth\/apple\/notifications$)/
2653
+ const LOOPBACK = ip => /^(?:127\.|::1$)/.test(ip)
2654
+ /**
2655
+ * Why a request to /api, /_brain or /s may not reach the backend, or null when it may. It passes with browser evidence
2656
+ * of this site (an Origin of this host or of access.origins, Sec-Fetch-Site same-origin, a same-host Referer), a
2657
+ * non-ambient credential the backend verifies (Authorization, X-Api-Key), a client address in access.ips, or a
2658
+ * loopback client while access.local is on. Headers can be forged: this keeps other sites' pages, foreign hosts and
2659
+ * plain scripts out, and the per-client rate limits bound what a forger reads.
2660
+ */
2661
+ export function accessRefusal(req, path, client, policy) {
2662
+ if (!policy || policy.mode !== 'own-host' || ACCESS_EXEMPT.test(path) || req.method === 'OPTIONS') return null
2663
+ const h = req.headers
2664
+ const host = String(h['x-forwarded-host'] || h.host || '').toLowerCase()
2665
+ const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
2666
+ const local = policy.local && LOOPBACK(client)
2667
+ if (policy.hosts.size && !policy.hosts.has(hostname) && !(local && ['localhost', '127.0.0.1', '::1'].includes(hostname))) return 'host'
2668
+ if (local) return null
2669
+ if (h.authorization || h['x-api-key']) return null
2670
+ if (policy.nets && client !== '?' && policy.nets.check(client, isIP(client) === 4 ? 'ipv4' : 'ipv6')) return null
2671
+ const own = value => {
2672
+ try {
2673
+ const url = new URL(value)
2674
+ return url.host === host || policy.origins.has(url.origin)
2675
+ } catch { return false }
2676
+ }
2677
+ if (h.origin) return own(h.origin) ? null : 'origin'
2678
+ const site = h['sec-fetch-site']
2679
+ const navigation = (req.method === 'GET' || req.method === 'HEAD') && h['sec-fetch-mode'] === 'navigate'
2680
+ if (site === 'same-origin') return null
2681
+ // a person who opened the URL, or a sign-in provider sending the browser back to its callback
2682
+ if (navigation && (site === 'none' || path.startsWith('/_brain/auth/'))) return null
2683
+ if (h.referer && own(h.referer)) return null
2684
+ return site ? 'cross-site' : 'no-browser-evidence'
2685
+ }
2686
+ /**
2687
+ * Whether a dynamic request (a page render, a form post, /api, /_brain, /s) stays inside its budget: 0, or the seconds
2688
+ * to wait. Checked before any body is read or the backend is asked, so a refused client costs one map lookup. A
2689
+ * loopback client while access.local is on, an address in access.ips and the exempt callbacks are not counted.
2690
+ */
2691
+ export function accessBudget(path, client, policy, key = 'edge ') {
2692
+ if (!policy || !policy.perMinute || ACCESS_EXEMPT.test(path)) return 0
2693
+ if (policy.local && LOOPBACK(client)) return 0
2694
+ if (policy.nets && client !== '?' && policy.nets.check(client, isIP(client) === 4 ? 'ipv4' : 'ipv6')) return 0
2695
+ return overLimit(key + client, policy.perMinute)
2696
+ }
2427
2697
  export function resolveClient(peer, forwarded, nets) {
2428
2698
  const socketPeer = plainIp(peer ?? '')
2429
2699
  const trusted = ip => nets.check(ip, isIP(ip) === 4 ? 'ipv4' : 'ipv6')
@@ -2499,10 +2769,23 @@ export function prefersMarkdown(accept) {
2499
2769
  if (html < 0) (html = any), (htmlAt = Infinity) // an explicit type beats a wildcard of the same weight
2500
2770
  return md > 0 && (md > html || (md === html && mdAt < htmlAt))
2501
2771
  }
2772
+ // Root-relative image metadata follows the app base; bare references follow the canonical document URL.
2773
+ export function metadataImage(root, canonical, value) {
2774
+ const encode = s => encodeURI(s.toWellFormed()).replaceAll('%25', '%')
2775
+ value = value.trim()
2776
+ if (!value) return ''
2777
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || !root) return encode(value)
2778
+ try {
2779
+ const home = new URL(root.endsWith('/') ? root : root + '/')
2780
+ const prefix = home.pathname.replace(/\/$/, '')
2781
+ if (/^\/(?!\/)/.test(value)) value = new URL(value === prefix || value.startsWith(prefix + '/') ? value : prefix + value, home.origin).href
2782
+ return new URL(value, canonical || home).href
2783
+ } catch { return encode(value) }
2784
+ }
2502
2785
  // SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
2503
2786
  // F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
2504
2787
  // A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
2505
- function head(page, seo, path, search = '') {
2788
+ function head(page, seo, path, search = '', route, base = '') {
2506
2789
  let h = page
2507
2790
  // `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
2508
2791
  let identity = '', query = ''
@@ -2514,11 +2797,13 @@ function head(page, seo, path, search = '') {
2514
2797
  })
2515
2798
  if (path) {
2516
2799
  // the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
2517
- const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m)) + identity
2800
+ const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(base + path) : m)) + identity
2518
2801
  h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2519
2802
  h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2520
2803
  }
2521
- if (!seo) return graph(h, path, query)
2804
+ if (!seo) return graph(h, path ? base + path : path, query)
2805
+ // Match prerendering: blank optional fields are absent; zero and false remain distinct values.
2806
+ seo = Object.fromEntries(Object.entries(seo).filter(([, v]) => v != null && !(typeof v === 'string' && !v.trim())))
2522
2807
  const meta = (attr, key, val, escaped) => {
2523
2808
  const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '&quot;') : esc(val)}">`
2524
2809
  const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
@@ -2531,20 +2816,27 @@ function head(page, seo, path, search = '') {
2531
2816
  else if (k === 'canonical') {
2532
2817
  // a root-relative canonical takes the origin the build wrote into the template (PUBLIC_SITE_URL), as it does on
2533
2818
  // a prerendered page: a relative canonical is ignored by search engines
2534
- const built = /^\/(?!\/)/.test(v) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2535
- const url = built ? built + v : v
2819
+ const relative = base && (v + '/').startsWith(base + '/') ? v.slice(base.length) || '/' : v
2820
+ const translated = linkTo(route, relative)
2821
+ const local = /^\/(?!\/)/.test(translated) ? base + translated : translated
2822
+ const built = /^\/(?!\/)/.test(local) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2823
+ const url = built ? built + local : local
2536
2824
  const tag = `<link rel="canonical" href="${esc(url)}">`
2537
2825
  h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
2538
2826
  if (seo['og:url'] == null) meta('property', 'og:url', url) // og:url follows the canonical unless the view sets its own
2539
2827
  } else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
2540
- else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
2828
+ else if (/^(og|twitter|article):/.test(k)) meta(k.startsWith('twitter:') ? 'name' : 'property', k, v)
2541
2829
  else if (typeof v === 'string') meta('name', k, v)
2542
2830
  }
2543
2831
  // What the build writes for a prerendered page and can only be known here: the social card of this request's values
2544
2832
  // (brain-seo social::head, prerender seo_head). The view's own value always wins.
2545
2833
  const shared = /<title>([\s\S]*?)<\/title>/i.exec(h)?.[1]
2546
2834
  const origin = /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2547
- if (typeof seo['og:image'] === 'string' && origin && /^\/(?!\/)/.test(seo['og:image'])) meta('property', 'og:image', encodeURI(origin + seo['og:image']))
2835
+ const canonical = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]+)"/i.exec(h)?.[1].replaceAll('&amp;', '&')
2836
+ for (const key of ['og:image', 'og:image:url', 'og:image:secure_url', 'twitter:image', 'twitter:image:src']) if (typeof seo[key] === 'string') {
2837
+ seo[key] = metadataImage(origin ? origin + base : null, canonical, seo[key])
2838
+ meta(key.startsWith('twitter:') ? 'name' : 'property', key, seo[key])
2839
+ }
2548
2840
  if (seo.title != null && seo['og:title'] == null) meta('property', 'og:title', shared, true)
2549
2841
  if (seo.description != null && seo['og:description'] == null) meta('property', 'og:description', seo.description)
2550
2842
  if (seo['og:image'] != null) {
@@ -2560,19 +2852,21 @@ function head(page, seo, path, search = '') {
2560
2852
  if (seo.title != null) node[/^(Article|BlogPosting|NewsArticle|TechArticle|Report)$/.test(T) ? 'headline' : 'name'] = String(seo.title).slice(0, 110)
2561
2853
  if (seo.description != null) node.description = String(seo.description)
2562
2854
  if (canon) node.url = canon[1].replace(/&amp;/g, '&')
2563
- if (seo['og:image'] != null) node.image = String(seo['og:image']).replace(/^\/(?!\/)/, () => (canon && /^https?:\/\/[^/]+/.exec(node.url) || [''])[0] + '/')
2855
+ if (seo['og:image'] != null) node.image = seo['og:image']
2564
2856
  if (seo.lastmod != null) node.dateModified = String(seo.lastmod)
2565
2857
  const own = new RegExp(`<script[^>]*type=["']application/ld\\+json["'][^>]*>\\s*\\{"@context":"https://schema\\.org","@type":"${T}"[\\s\\S]*?</script>`, 'i')
2566
2858
  const tag = `<script type="application/ld+json">${jsonInHtml(node)}</script>`
2567
2859
  h = own.test(h) ? h.replace(own, () => tag) : h.replace('</head>', () => tag + '</head>')
2568
2860
  }
2569
2861
  if (seo.$head) {
2570
- for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi))
2571
- h = h.replace(new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
2862
+ for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi)) {
2863
+ const attributes = key.startsWith('twitter:') ? '(?:property|name)' : attr
2864
+ h = h.replace(new RegExp(`<meta[^>]*\\b${attributes}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
2865
+ }
2572
2866
  if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
2573
2867
  h = h.replace('</head>', () => seo.$head + '</head>')
2574
2868
  }
2575
- return graph(h, path, query, seo)
2869
+ return graph(h, path ? base + path : path, query, seo)
2576
2870
  }
2577
2871
  // The WebSite + WebPage graph the build wrote into the template (brain-seo ServerHead::apply): its pattern URLs become
2578
2872
  // this request's, like the canonical, and the WebPage takes this request's title and description.
@@ -2588,27 +2882,32 @@ function graph(h, path, query, seo) {
2588
2882
  if ([node['@type']].flat().includes('WebPage')) {
2589
2883
  if (title && node.name == null) node.name = title.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
2590
2884
  if (seo?.description != null && node.description == null) node.description = String(seo.description)
2885
+ if (typeof seo?.['og:image'] === 'string' && seo.jsonld == null) {
2886
+ const image = node.primaryImageOfPage
2887
+ if (!image || (image['@type'] === 'ImageObject' && !image['@id'])) node.primaryImageOfPage = { '@type': 'ImageObject', ...image, url: seo['og:image'] }
2888
+ }
2591
2889
  }
2592
2890
  }
2593
2891
  return a + jsonInHtml(g) + b
2594
2892
  })
2595
2893
  }
2596
- // `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
2894
+ // Only compiler-owned chunks receive the own-extension module format; ordinary Node imports keep their loader.
2895
+ function resolveChunk(s, c, next, base, app) {
2896
+ if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
2897
+ const mapped = s.startsWith('/_b/')
2898
+ const url = mapped ? new URL('.' + s, base) : s.startsWith('file:') ? new URL(s) : s.startsWith('.') && c.parentURL?.startsWith('file:') ? new URL(s, c.parentURL) : null
2899
+ const owned = new URL('_b/', base).href
2900
+ if (url?.href.startsWith(owned) && url.pathname.endsWith('.jsbrain')) return { url: url.href, format: 'module', shortCircuit: true }
2901
+ if (mapped) return { url: url.href, shortCircuit: true }
2902
+ return next(s, c)
2903
+ }
2597
2904
  let hooked = false
2598
2905
  function hook(base, app = '/') {
2599
2906
  if (hooked) return
2600
2907
  hooked = true
2601
- // chunks import '/_b/x.js', or '/app/_b/x.js' under a base path: both are dist/_b/x.js
2602
- const own = s => {
2603
- if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
2604
- return s.startsWith('/_b/') && { url: new URL('.' + s, base).href, shortCircuit: true }
2605
- }
2606
- // registerHooks (Node 22.15+, in-thread) replaces the deprecated register (DEP0205)
2607
- if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => own(s) || next(s, c) })
2608
- Module.register(
2609
- 'data:text/javascript,' +
2610
- encodeURIComponent(`export async function resolve(s, c, n) { const a = ${json(app)}; if (a !== '/' && s.startsWith(a + '_b/')) s = s.slice(a.length - 1); return s.startsWith('/_b/') ? { url: new URL('.' + s, ${json(base)}).href, shortCircuit: true } : n(s, c) }`)
2611
- )
2908
+ // registerHooks (Node 22.15+, in-thread) and the compatibility loader share exactly the same resolver.
2909
+ if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => resolveChunk(s, c, next, base, app) })
2910
+ Module.register('data:text/javascript,' + encodeURIComponent(`${resolveChunk.toString()};export async function resolve(s,c,n){return resolveChunk(s,c,n,${json(base)},${json(app)})}`))
2612
2911
  }
2613
2912
 
2614
2913
  const LISTEN_WAIT_MS = 10000