@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/brain-data.js +5 -2
- package/brain-env.js +8 -6
- package/brain-form.js +46 -36
- package/brain-inspect.js +19 -6
- package/brain-island.js +2 -1
- package/brain-lazy.js +2 -0
- package/brain-links.js +37 -0
- package/brain-live.js +21 -5
- package/brain-observe.js +120 -0
- package/brain-router.js +224 -178
- package/brain-schema.js +41 -9
- package/brain-trace.js +269 -0
- package/brain-vitals.js +60 -0
- package/package.json +1 -1
- package/server/llm.mjs +199 -0
- package/server/native-sockets.mjs +13 -10
- package/server/server.mjs +663 -105
- package/shim/dom.js +42 -7
- package/sw/brain-sw.js +17 -9
- package/ui/error-state.brain +1 -1
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-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
296
|
-
if (
|
|
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(
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
480
|
-
if (!req.$nativeAuth) req.$nativeAuth =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
const
|
|
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 } =
|
|
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
|
-
//
|
|
759
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1277
|
-
|
|
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
|
|
1280
|
-
|
|
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))
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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 =
|
|
1302
|
-
if (!secret) throw
|
|
1303
|
-
const
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (/
|
|
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, '"') : 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.
|
|
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 = () => {
|