@christianirack/brainllm-runtime 0.8.11 → 0.8.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@christianirack/brainllm-runtime",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
4
4
  "description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
5
5
  "type": "module",
6
6
  "exports": {
package/server/server.mjs CHANGED
@@ -28,7 +28,7 @@ import { BlockList, isIP } from 'node:net'
28
28
  import { Readable } from 'node:stream'
29
29
  import zlib from 'node:zlib'
30
30
  import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
31
- import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
31
+ import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync, statSync, lstatSync } from 'node:fs'
32
32
  import * as Module from 'node:module'
33
33
  import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
34
34
  import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -201,6 +201,17 @@ function bodyEncoding(req, status, headers) {
201
201
  if (status < 200 || status === 204 || status === 206 || status === 304 || headers['content-encoding'] || !COMPRESSIBLE.test(headers['content-type'] || '') || /no-transform/i.test(headers['cache-control'] || '')) return null
202
202
  return acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] || ''
203
203
  }
204
+ // One `bytes=` span of a file of `size` bytes (RFC 9110 §14): { start, end } inclusive, false when it cannot be
205
+ // satisfied (416), or null when the header is not one well-formed span (several, another unit, reversed): the whole
206
+ // file is sent then, which the RFC allows.
207
+ function byteRange(header, size) {
208
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
209
+ if (!m || (m[1] === '' && m[2] === '')) return null
210
+ if (m[1] === '') return Number(m[2]) > 0 && size > 0 ? { start: Math.max(0, size - Number(m[2])), end: size - 1 } : false
211
+ const start = Number(m[1])
212
+ if (m[2] !== '' && Number(m[2]) < start) return null
213
+ return start < size ? { start, end: m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1) } : false
214
+ }
204
215
  const encodeBody = (enc, buf) => enc === 'zstd' ? zlib.zstdCompressSync(buf) : enc === 'br' ? zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) : zlib.gzipSync(buf)
205
216
  const encodeStream = enc => enc === 'zstd' ? zlib.createZstdCompress() : enc === 'br' ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }) : zlib.createGzip()
206
217
 
@@ -220,11 +231,14 @@ function googleSignInPage(page) {
220
231
  for (const item of tag.slice(5, -1).matchAll(/([^\s=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
221
232
  const key = item[1].toLowerCase(); if (!attrs.has(key)) attrs.set(key, item[2] ?? item[3] ?? item[4] ?? '')
222
233
  }
223
- if (attrs.get('name')?.toLowerCase() === 'brain-google-signin' && attrs.get('content') === '1') return true
234
+ // Google's and Apple's sign-in popups both answer through window.opener.
235
+ if (['brain-google-signin', 'brain-apple-signin'].includes(attrs.get('name')?.toLowerCase()) && attrs.get('content') === '1') return true
224
236
  }
225
237
  return false
226
238
  }
227
239
 
240
+ // One-use provider cookies (Google nonce, Apple nonce, LinkedIn state): relayed to the backend and cleared at logout.
241
+ const PROVIDER_COOKIES = ['__Host-brain-google', '__Host-brain-apple', '__Host-brain-linkedin']
228
242
  // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
229
243
  // ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
230
244
  function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
@@ -365,7 +379,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
365
379
  for (const value of backend.headers['set-cookie'] || []) {
366
380
  if (value.length > 8192 || /[\r\n]/.test(value)) return finish(true)
367
381
  const name = value.split('=', 1)[0]
368
- if (names.has(name) || name === '__Host-brain-google') cleared.push(value)
382
+ if (names.has(name) || PROVIDER_COOKIES.includes(name)) cleared.push(value)
369
383
  }
370
384
  finish(false, { 'set-cookie': cleared, 'clear-site-data': '"storage"' })
371
385
  })
@@ -439,14 +453,14 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
439
453
  const selected = []
440
454
  for (const part of String(req.headers.cookie || '').split(';')) {
441
455
  const name = part.trim().split('=')[0]
442
- if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
456
+ if (['__Host-brain','__Host-brain-access','__Host-brain-refresh',...PROVIDER_COOKIES].includes(name)) selected.push(part.trim())
443
457
  }
444
458
  if (selected.length) headers.cookie = selected.join('; ')
445
459
  const client = forwardedFor(req)
446
460
  if (client) headers['x-forwarded-for'] = client
447
461
  return headers
448
462
  }
449
- const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
463
+ const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google|-apple|-linkedin)?)=/.test(value) && value.length <= 8192).slice(0,8)
450
464
  // /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
451
465
  function fileStream(req, res, url, rid) {
452
466
  if (stopped || streams >= 32) throw bridgeBusy('Backend stream capacity is unavailable')
@@ -1027,6 +1041,48 @@ export function createServer(o = {}) {
1027
1041
  }
1028
1042
  return resolveClient(req.socket.remoteAddress, forwarded, proxies)
1029
1043
  }
1044
+ // platform.access (docs/backend/access.md): /api, /_brain and /s answer this site only. Refused sources are counted
1045
+ // per client address (at most 512, the oldest dropped) for `brain access sources`, and logged at most 16 a minute.
1046
+ const access = accessPolicy(o.access ?? mf.access ?? {}, mf.site || env.PUBLIC_SITE_URL)
1047
+ const refusedSources = new Map()
1048
+ const budgetKey = `edge ${++servers} `
1049
+ let accessWindow = 0, accessLogs = 0, inFlight = 0, limitedWindow = 0, limited = 0, limitedLogs = 0
1050
+ // the dynamic budget (accessBudget) and the in-flight cap: past either the answer is an immediate 429 or 503 with
1051
+ // Retry-After, no queue, and one log line per minute sums what was turned away
1052
+ const overBudget = (req, res, path, client) => {
1053
+ const wait = access ? accessBudget(path, client, access, budgetKey) : 0
1054
+ const busy = !wait && access.concurrency !== null && !ACCESS_EXEMPT.test(path) && inFlight >= access.concurrency
1055
+ if (!wait && !busy) {
1056
+ if (access.concurrency !== null) { inFlight++; res.once('close', () => inFlight--) }
1057
+ return false
1058
+ }
1059
+ const minute = Math.floor(Date.now() / 60000)
1060
+ if (limitedWindow !== minute) {
1061
+ if (limited) log('warn', 'requests limited', { count: limited, minute: new Date(limitedWindow * 60000).toISOString(), fix: 'raise platform.access.perMinute or concurrency if real visitors are among them' })
1062
+ limitedWindow = minute; limited = 0; limitedLogs = 0
1063
+ }
1064
+ limited++
1065
+ if (limitedLogs++ < 4) log('warn', 'request limited', { ip: client, path: String(path).slice(0, 256), reason: busy ? 'concurrency' : 'perMinute', userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'].slice(0, 256) : null })
1066
+ send(res, busy ? 503 : 429, busy ? 'Busy, try again shortly' : 'Too many requests', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'retry-after': String(busy ? 2 : wait) })
1067
+ return true
1068
+ }
1069
+ const accessRefused = (req, rid, path, client, reason) => {
1070
+ const bound = v => typeof v === 'string' ? v.slice(0, 256) : null
1071
+ const now = new Date().toISOString()
1072
+ let entry = refusedSources.get(client)
1073
+ if (entry) refusedSources.delete(client)
1074
+ else {
1075
+ if (refusedSources.size >= 512) refusedSources.delete(refusedSources.keys().next().value)
1076
+ entry = { ip: client, count: 0, first: now, reasons: {} }
1077
+ }
1078
+ entry.count++; entry.last = now; entry.reasons[reason] = (entry.reasons[reason] || 0) + 1
1079
+ Object.assign(entry, { path: bound(path), origin: bound(req.headers.origin), referer: bound(req.headers.referer), host: bound(req.headers['x-forwarded-host'] || req.headers.host), userAgent: bound(req.headers['user-agent']), country: bound(req.headers['cf-ipcountry']) })
1080
+ refusedSources.set(client, entry)
1081
+ const minute = Math.floor(Date.now() / 60000)
1082
+ if (accessWindow !== minute) { accessWindow = minute; accessLogs = 0 }
1083
+ if (accessLogs++ >= 16) return
1084
+ log('warn', 'access refused', { code: 'B0752', reason, rid, ip: client, path: entry.path, origin: entry.origin, referer: entry.referer, secFetchSite: bound(req.headers['sec-fetch-site']), host: entry.host, userAgent: entry.userAgent, fix: 'the native API answers this site only; a page of the site, an API key, or an address in platform.access.ips passes. Run brain explain B0752' })
1085
+ }
1030
1086
  // server/routes/* of the native backend, listed by the build: only these exact shapes cross the bridge
1031
1087
  const nativeRoutes = (Array.isArray(mf.nativeRoutes) ? mf.nativeRoutes : []).map(route => {
1032
1088
  if (typeof route?.path !== 'string' || !Array.isArray(route.headers)) throw Error('Manifest nativeRoutes must list path and headers')
@@ -1833,6 +1889,19 @@ export function createServer(o = {}) {
1833
1889
  'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
1834
1890
  vary: 'accept-encoding',
1835
1891
  }
1892
+ // Range: media players ask for byte spans (Safari plays no video without them). The span is of the plain file, so
1893
+ // a ranged request skips the precompressed siblings; If-Range has no validator to match here, so it gets it all.
1894
+ const span = req.headers.range && !req.headers['if-range'] ? byteRange(req.headers.range, statSync(f).size) : null
1895
+ if (span === false) return send(res, 416, '', { 'content-range': `bytes */${statSync(f).size}` }), true
1896
+ if (span) {
1897
+ const len = span.end - span.start + 1, buf = Buffer.alloc(req.method === 'HEAD' ? 0 : len)
1898
+ if (buf.length) {
1899
+ const fd = openSync(f, 'r')
1900
+ try { readSync(fd, buf, 0, len, span.start) } finally { closeSync(fd) }
1901
+ }
1902
+ send(res, 206, buf, { ...headers, 'accept-ranges': 'bytes', 'content-range': `bytes ${span.start}-${span.end}/${statSync(f).size}`, 'content-length': String(len) })
1903
+ return true
1904
+ }
1836
1905
  // the smallest precompressed sibling the client accepts (the build keeps only those smaller than the file)
1837
1906
  let best = null
1838
1907
  for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
@@ -1844,7 +1913,7 @@ export function createServer(o = {}) {
1844
1913
  send(res, 200, req.method === 'HEAD' ? '' : readFileSync(best.encoded), { ...headers, 'content-encoding': best.enc })
1845
1914
  return true
1846
1915
  }
1847
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), headers)
1916
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), { ...headers, 'accept-ranges': 'bytes' })
1848
1917
  return true
1849
1918
  }
1850
1919
  return false
@@ -1936,6 +2005,11 @@ export function createServer(o = {}) {
1936
2005
  let full
1937
2006
  try { full = new URL(req.url, 'http://' + host) } catch { return send(res, 400, 'Bad Request') }
1938
2007
  // /_health stays at the root: the supervisor calls it there whatever the base
2008
+ // refused API sources, for an operator on this machine only (a loopback socket and no forwarded hop)
2009
+ if (full.pathname === '/_access/sources' && req.method === 'GET') {
2010
+ if (!LOOPBACK(plainIp(req.socket.remoteAddress ?? '') || '') || req.headers['x-forwarded-for']) return notFound(req, res)
2011
+ return send(res, 200, json({ sources: [...refusedSources.values()].sort((a, b) => b.count - a.count) }), { 'content-type': MIME['.json'], 'cache-control': 'no-store' })
2012
+ }
1939
2013
  if (full.pathname === '/_health' || full.pathname === BASE + '_health') return send(res, 200, json({ ok: true, build: mf.build || null }), { 'content-type': MIME['.json'], 'cache-control': 'no-store' })
1940
2014
  let url = full
1941
2015
  if (BASE !== '/') {
@@ -1950,9 +2024,20 @@ export function createServer(o = {}) {
1950
2024
  const probe = brainProbe(url.pathname)
1951
2025
  if (probe) return await answerProbe(req, res, new URL(probe, url), rid)
1952
2026
  if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
1953
- if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
2027
+ if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) {
2028
+ const client = ipOf(req), refused = accessRefusal(req, url.pathname, client, access)
2029
+ if (refused) {
2030
+ accessRefused(req, rid, url.pathname, client, refused)
2031
+ return send(res, 403, json({ error: 'This API answers its own site only', code: 'B0752' }), { 'content-type': MIME['.json'], 'cache-control': 'no-store', 'x-brain-code': 'B0752' })
2032
+ }
2033
+ if (overBudget(req, res, url.pathname, client)) return
2034
+ return await native.proxy(req,res,url,rid,send)
2035
+ }
1954
2036
  const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
1955
- if (declaredRoute) return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
2037
+ if (declaredRoute) {
2038
+ if (overBudget(req, res, url.pathname, ipOf(req))) return
2039
+ return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
2040
+ }
1956
2041
  // redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
1957
2042
  // same-site targets get the base, the query is kept when the target has none; slash policy and host in the same hop
1958
2043
  const to = (req.method === 'GET' || req.method === 'HEAD') && canonicalOf(req, url)
@@ -1985,6 +2070,7 @@ export function createServer(o = {}) {
1985
2070
  const paged = post ? null : pageParameter(route, url)
1986
2071
  if (paged === 404) return notFound(req, res)
1987
2072
  if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
2073
+ if (overBudget(req, res, url.pathname, ipOf(req))) return
1988
2074
  return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
1989
2075
  }
1990
2076
  if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
@@ -2203,6 +2289,7 @@ export async function runChain({ guards = [], middleware = [], ctx }) {
2203
2289
  // key past HITS_KEY_MAX characters is stored as its digest. The map stays ordered by window start: a new window is
2204
2290
  // re-inserted at the end, so the expired entries are always the first ones.
2205
2291
  const HITS = new Map() // key → [windowStart, count]
2292
+ let servers = 0 // each createServer keeps its own dynamic budget (accessBudget) in HITS
2206
2293
  export const HITS_MAX = 50000, HITS_KEY_MAX = 256
2207
2294
  export const rateLimitKeys = () => HITS.size
2208
2295
  const WINDOW = 60000
@@ -2424,6 +2511,94 @@ export function trustedProxies(list = []) {
2424
2511
  * The client is the right-most forwarded hop that is not a trusted proxy, and only when the TCP peer is trusted: hops
2425
2512
  * left of it are client-supplied and never consulted. Same walk as resolve_client (brain-backend/src/edge_http.rs).
2426
2513
  */
2514
+ /**
2515
+ * platform.access (docs/backend/access.md): who may reach the dynamic answers and how much each client may take.
2516
+ * `mode` "open" (default) lets any client in, scripts and headless browsers included; "own-host" refuses /api, /_brain
2517
+ * and /s to anything without evidence of this site. Nothing is limited unless the app sets `perMinute` (dynamic
2518
+ * requests per client and minute) or `concurrency` (dynamic requests in flight for the whole process). Normalizes `{ mode, perMinute, concurrency, origins, hosts, ips,
2519
+ * local }`; `site` is the site origin (PUBLIC_SITE_URL), whose host is always one of the hosts.
2520
+ * Same limits as the backend's parse_access (brain-backend/src/platform.rs).
2521
+ */
2522
+ export function accessPolicy(raw = {}, site) {
2523
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw Error('platform.access must be an object')
2524
+ for (const key of Object.keys(raw)) if (!['mode', 'perMinute', 'concurrency', 'origins', 'hosts', 'ips', 'local'].includes(key)) throw Error(`platform.access.${key} is not a setting; use mode, perMinute, concurrency, origins, hosts, ips or local`)
2525
+ const list = (key, max) => {
2526
+ const value = raw[key] ?? []
2527
+ if (!Array.isArray(value) || value.length > max || value.some(v => typeof v !== 'string' || !v || v.length > 256)) throw Error(`platform.access.${key} must be an array of at most ${max} strings`)
2528
+ return value
2529
+ }
2530
+ const origins = new Set()
2531
+ for (const origin of list('origins', 32)) {
2532
+ let parsed
2533
+ try { parsed = new URL(origin) } catch { throw Error(`platform.access.origins entry \`${origin}\` must be an origin such as https://admin.example.com`) }
2534
+ if (parsed.origin !== origin || !/^https?:$/.test(parsed.protocol)) throw Error(`platform.access.origins entry \`${origin}\` must be exactly scheme://host[:port], without a path or a trailing slash`)
2535
+ origins.add(origin)
2536
+ }
2537
+ const hosts = new Set()
2538
+ for (const host of list('hosts', 32)) {
2539
+ if (!/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/.test(host)) throw Error(`platform.access.hosts entry \`${host}\` must be a lowercase host name without scheme, port or wildcard`)
2540
+ hosts.add(host)
2541
+ }
2542
+ try { if (site) hosts.add(new URL(site).hostname) } catch {}
2543
+ const ips = list('ips', 64)
2544
+ const nets = ips.length ? trustedProxies(ips) : null
2545
+ if (raw.local !== undefined && typeof raw.local !== 'boolean') throw Error('platform.access.local must be true or false')
2546
+ const mode = raw.mode ?? 'open'
2547
+ if (mode !== 'open' && mode !== 'own-host') throw Error('platform.access.mode must be "open" or "own-host"')
2548
+ const count = (key, max) => {
2549
+ const value = raw[key] ?? null
2550
+ if (value !== null && (!Number.isInteger(value) || value < 1 || value > max)) throw Error(`platform.access.${key} must be a whole number from 1 to ${max}`)
2551
+ return value
2552
+ }
2553
+ return Object.freeze({ mode, perMinute: count('perMinute', 100000), concurrency: count('concurrency', 10000), origins, hosts, nets, local: raw.local !== false })
2554
+ }
2555
+ // Paths a third party reaches on purpose: links, the analytics script, signed inbound webhooks and Apple's
2556
+ // server-to-server notifications. Each carries its own check.
2557
+ const ACCESS_EXEMPT = /^\/(?:s\/|_brain\/analytics\.js$|_brain\/webhooks\/in\/[a-z][a-z0-9_]{0,47}$|_brain\/auth\/apple\/notifications$)/
2558
+ const LOOPBACK = ip => /^(?:127\.|::1$)/.test(ip)
2559
+ /**
2560
+ * Why a request to /api, /_brain or /s may not reach the backend, or null when it may. It passes with browser evidence
2561
+ * of this site (an Origin of this host or of access.origins, Sec-Fetch-Site same-origin, a same-host Referer), a
2562
+ * non-ambient credential the backend verifies (Authorization, X-Api-Key), a client address in access.ips, or a
2563
+ * loopback client while access.local is on. Headers can be forged: this keeps other sites' pages, foreign hosts and
2564
+ * plain scripts out, and the per-client rate limits bound what a forger reads.
2565
+ */
2566
+ export function accessRefusal(req, path, client, policy) {
2567
+ if (!policy || policy.mode !== 'own-host' || ACCESS_EXEMPT.test(path) || req.method === 'OPTIONS') return null
2568
+ const h = req.headers
2569
+ const host = String(h['x-forwarded-host'] || h.host || '').toLowerCase()
2570
+ const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
2571
+ const local = policy.local && LOOPBACK(client)
2572
+ if (policy.hosts.size && !policy.hosts.has(hostname) && !(local && ['localhost', '127.0.0.1', '::1'].includes(hostname))) return 'host'
2573
+ if (local) return null
2574
+ if (h.authorization || h['x-api-key']) return null
2575
+ if (policy.nets && client !== '?' && policy.nets.check(client, isIP(client) === 4 ? 'ipv4' : 'ipv6')) return null
2576
+ const own = value => {
2577
+ try {
2578
+ const url = new URL(value)
2579
+ return url.host === host || policy.origins.has(url.origin)
2580
+ } catch { return false }
2581
+ }
2582
+ if (h.origin) return own(h.origin) ? null : 'origin'
2583
+ const site = h['sec-fetch-site']
2584
+ const navigation = (req.method === 'GET' || req.method === 'HEAD') && h['sec-fetch-mode'] === 'navigate'
2585
+ if (site === 'same-origin') return null
2586
+ // a person who opened the URL, or a sign-in provider sending the browser back to its callback
2587
+ if (navigation && (site === 'none' || path.startsWith('/_brain/auth/'))) return null
2588
+ if (h.referer && own(h.referer)) return null
2589
+ return site ? 'cross-site' : 'no-browser-evidence'
2590
+ }
2591
+ /**
2592
+ * Whether a dynamic request (a page render, a form post, /api, /_brain, /s) stays inside its budget: 0, or the seconds
2593
+ * to wait. Checked before any body is read or the backend is asked, so a refused client costs one map lookup. A
2594
+ * loopback client while access.local is on, an address in access.ips and the exempt callbacks are not counted.
2595
+ */
2596
+ export function accessBudget(path, client, policy, key = 'edge ') {
2597
+ if (!policy || !policy.perMinute || ACCESS_EXEMPT.test(path)) return 0
2598
+ if (policy.local && LOOPBACK(client)) return 0
2599
+ if (policy.nets && client !== '?' && policy.nets.check(client, isIP(client) === 4 ? 'ipv4' : 'ipv6')) return 0
2600
+ return overLimit(key + client, policy.perMinute)
2601
+ }
2427
2602
  export function resolveClient(peer, forwarded, nets) {
2428
2603
  const socketPeer = plainIp(peer ?? '')
2429
2604
  const trusted = ip => nets.check(ip, isIP(ip) === 4 ? 'ipv4' : 'ipv6')
package/sw/brain-sw.js CHANGED
@@ -18,6 +18,8 @@ const BASE = B.base || '/' // app served under a base path (§23 #148); precache
18
18
  const NS = 'brain' + BASE.replace(/\/$/, '') // `brain` at the root, `brain/app` under /app/
19
19
  const BUILD = NS + '-' + B.hash, META = NS + ':meta'
20
20
  const NAV_TIMEOUT = 3000
21
+ // precache downloads at once: hundreds in one burst tripped per-IP proxy rate limits (429) and competed with the page
22
+ const PRECACHE_PARALLEL = 4
21
23
  // responses marked personal or uncacheable are never stored
22
24
  const storable = r => r.ok && !/no-store|private/i.test(r.headers.get('cache-control') || '')
23
25
  const ROUTES = (B.routes || []).map(r => ({ ...r, cache: NS + ':route:' + r.match + (r.prefix ? '*' : '') }))
@@ -34,9 +36,28 @@ self.addEventListener('install', e => {
34
36
  e.waitUntil(
35
37
  (async () => {
36
38
  const c = await caches.open(BUILD)
39
+ // a _b/ file is content-hashed: the same URL in an earlier build of this app holds the same bytes, so an
40
+ // update downloads only what changed
41
+ const olds = (await caches.keys()).filter(k => k.startsWith(NS + '-') && k !== BUILD)
42
+ const kept = async u => {
43
+ if (!u.startsWith(BASE + '_b/')) return null
44
+ for (const k of olds) {
45
+ const r = await (await caches.open(k)).match(u)
46
+ if (r) return r
47
+ }
48
+ return null
49
+ }
50
+ // default cache mode: the page may have just filled the HTTP cache with these immutable files
51
+ const one = async u => {
52
+ const r = (await kept(u)) || (await fetch(u))
53
+ if (r.ok) await c.put(u, r)
54
+ }
37
55
  // one failing URL must not abort the install: cache what answers, the rest is cached on first use
38
- // default cache mode: the page just filled the HTTP cache with these immutable files, so this costs no bytes
39
- await Promise.allSettled(B.precache.map(u => fetch(u).then(r => r.ok && c.put(u, r))))
56
+ const queue = [...B.precache]
57
+ const lane = async () => {
58
+ while (queue.length) await one(queue.shift()).catch(() => {})
59
+ }
60
+ await Promise.all(Array.from({ length: Math.min(PRECACHE_PARALLEL, queue.length) }, lane))
40
61
  // update: 'prompt' → an update waits for the page (appUpdate); the first install has nothing to replace
41
62
  if (B.update !== 'prompt' || !self.registration.active) await self.skipWaiting()
42
63
  })()
@@ -0,0 +1,117 @@
1
+ // Application-owned Sign in with Apple JS adapter (popup flow). No tokens, private errors or session data are logged or stored.
2
+ const SDK = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js'
3
+ let loading = null, active = null
4
+ const sdk = () => {
5
+ if (globalThis.AppleID?.auth) return Promise.resolve(globalThis.AppleID.auth)
6
+ if (loading) return loading
7
+ loading = new Promise((resolve, reject) => {
8
+ const doc = globalThis.document, existing = doc.querySelector(`script[src="${SDK}"]`)
9
+ const script = existing || doc.createElement('script')
10
+ const done = error => {
11
+ clearTimeout(timer); script.removeEventListener('load', loaded); script.removeEventListener('error', failed)
12
+ if (error) { if (!existing) script.remove(); reject(Error('Apple sign-in is unavailable')) }
13
+ else resolve(globalThis.AppleID.auth)
14
+ }
15
+ const loaded = () => globalThis.AppleID?.auth ? done(false) : done(true), failed = () => done(true)
16
+ const timer = setTimeout(failed, 6000)
17
+ script.addEventListener('load', loaded); script.addEventListener('error', failed)
18
+ if (!existing) { script.src = SDK; script.async = true; script.defer = true; doc.head.appendChild(script) }
19
+ }).catch(error => { loading = null; throw error })
20
+ return loading
21
+ }
22
+ // Apple sends the name only on the first authorization and unsigned: it is a display hint, never an identity.
23
+ const name = user => {
24
+ const first = typeof user?.name?.firstName === 'string' ? user.name.firstName.trim() : ''
25
+ const last = typeof user?.name?.lastName === 'string' ? user.name.lastName.trim() : ''
26
+ const full = `${first} ${last}`.trim()
27
+ return full && full.length <= 200 ? full : undefined
28
+ }
29
+ export default {
30
+ mount(element, initial = {}) {
31
+ if (active) throw Error('Only one Apple sign-in widget may be active')
32
+ let props = initial, alive = true, busy = false, pending = null
33
+ const owner = {}; active = owner
34
+ const notify = (message, waiting, signedIn = false) => {
35
+ if (!alive) return
36
+ busy = waiting; element.setAttribute('aria-busy', String(waiting))
37
+ if ('inert' in element) element.inert = waiting
38
+ if (typeof props.onState === 'function') props.onState({ message, busy: waiting, signedIn })
39
+ }
40
+ const request = async (path, body, method = 'POST') => {
41
+ const controller = new AbortController(); pending = controller
42
+ const timer = setTimeout(() => controller.abort(), 8000)
43
+ try {
44
+ const base = globalThis.document?.querySelector('meta[name=brain-base]')?.getAttribute('content') || ''
45
+ if (base && (!base.startsWith('/') || base.startsWith('//') || /[\\?#\x00-\x20]/.test(base))) throw Error('Invalid application base')
46
+ const response = await fetch(base.replace(/\/$/, '') + path, { method, credentials: 'same-origin', headers: body == null ? {} : { 'content-type': 'application/json' }, ...(body == null ? {} : { body: JSON.stringify(body) }), signal: controller.signal })
47
+ if (!response.ok) return { $status: response.status }
48
+ return await response.json()
49
+ } finally { clearTimeout(timer); if (pending === controller) pending = null }
50
+ }
51
+ const failed = () => notify('Sign-in could not be completed. Retry or use email sign-in.', false)
52
+ const start = async () => {
53
+ if (!alive || busy) return
54
+ notify('Preparing secure sign-in.', true)
55
+ try {
56
+ const current = await request('/_brain/auth/me', null, 'GET')
57
+ if (!alive) return
58
+ if (current?.user) { element.replaceChildren(); return notify('You are signed in.', false, true) }
59
+ if (current?.$status !== 401) return failed()
60
+ const renewed = await request('/_brain/auth/refresh')
61
+ if (!alive) return
62
+ if (renewed?.user) { notify('You are signed in.', false, true); globalThis.location?.reload(); return }
63
+ if (renewed?.$status !== 401) return failed()
64
+ const challenge = await request('/_brain/auth/apple/challenge')
65
+ if (!alive) return
66
+ if (!challenge || !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(challenge.client_id) || !/^[a-f0-9]{64}$/.test(challenge.nonce) || typeof challenge.redirect_uri !== 'string') return failed()
67
+ const auth = await sdk()
68
+ if (!alive) return
69
+ auth.init({ clientId: challenge.client_id, scope: 'name email', redirectURI: challenge.redirect_uri, nonce: challenge.nonce, usePopup: true })
70
+ const button = globalThis.document.createElement('button')
71
+ button.type = 'button'; button.className = 'brain-apple-signin'; button.textContent = 'Sign in with Apple'
72
+ button.addEventListener('click', async () => {
73
+ if (!alive || busy) return
74
+ notify('Completing secure sign-in.', true)
75
+ try {
76
+ const result = await auth.signIn()
77
+ const token = result?.authorization?.id_token
78
+ if (!alive) return
79
+ if (typeof token !== 'string' || token.length > 16384) return failed()
80
+ const session = await request('/_brain/auth/apple', { id_token: token, ...(name(result.user) ? { name: name(result.user) } : {}) })
81
+ if (!alive) return
82
+ if (!session?.user) return failed()
83
+ notify('You are signed in.', false, true)
84
+ // A full auth-boundary reload forgets all prior user-specific in-memory application state.
85
+ globalThis.location?.reload()
86
+ } catch {
87
+ // A closed popup (`popup_closed_by_user`) is a choice, not an error.
88
+ if (alive) notify('Choose Apple sign-in or use email sign-in.', false)
89
+ }
90
+ })
91
+ element.replaceChildren(button)
92
+ notify('Choose Apple sign-in or use email sign-in.', false)
93
+ } catch { if (alive) failed() }
94
+ }
95
+ void start()
96
+ return {
97
+ update(next) {
98
+ props = next || {}
99
+ if (!alive || busy) return
100
+ if (props.action === 'logout') {
101
+ notify('Signing out.', true)
102
+ void request('/_brain/auth/logout').then(result => {
103
+ if (!alive) return
104
+ if (!result?.ok) return failed()
105
+ notify('You are signed out.', false); globalThis.location?.reload()
106
+ }, () => { if (alive) failed() })
107
+ } else void start()
108
+ },
109
+ destroy() {
110
+ if (!alive) return
111
+ alive = false; pending?.abort(); element.replaceChildren(); element.removeAttribute('aria-busy')
112
+ if ('inert' in element) element.inert = false
113
+ if (active === owner) active = null
114
+ },
115
+ }
116
+ },
117
+ }