@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 +1 -1
- package/server/server.mjs +183 -8
- package/sw/brain-sw.js +23 -2
- package/templates/apple-signin.js +117 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.8.
|
|
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
|
-
|
|
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
|
|
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'
|
|
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))
|
|
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)
|
|
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
|
-
|
|
39
|
-
|
|
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
|
+
}
|