acrylblends 1.5.0 → 1.5.2

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.
@@ -26,6 +26,8 @@ export interface LoopbackOptions {
26
26
  * same-origin` and a `Referer` from this authority. `mutations` is not consulted: every request is held to that rule.
27
27
  */
28
28
  readonly callers?: 'local' | 'own-page'
29
+ /** The hosted app's trusted front; default: the one the environment configures ({@link trustedFront}), usually none. Tests pass one. */
30
+ readonly front?: TrustedFront | undefined
29
31
  }
30
32
 
31
33
  const loopback = new BlockList()
@@ -45,7 +47,7 @@ export function loopbackAuthority(c: Context, hostnames: readonly string[] = LOO
45
47
  const { remoteAddress, localPort } = connectionOf(c)
46
48
  const host = c.req.header('host')
47
49
  if (remoteAddress === undefined || localPort === undefined || host === undefined) return undefined
48
- const address = (remoteAddress.replace(/^\[|\]$/gu, '').split('%', 1)[0] ?? '').replace(/^::ffff:/iu, '')
50
+ const address = peerAddress(remoteAddress)
49
51
  const family = isIP(address)
50
52
  if (family === 0 || !loopback.check(address, family === 4 ? 'ipv4' : 'ipv6')) return undefined
51
53
  let authority: URL
@@ -63,13 +65,73 @@ export function loopbackAuthority(c: Context, hostnames: readonly string[] = LOO
63
65
  return authority
64
66
  }
65
67
 
68
+ /**
69
+ * A hosted app's one trusted front (spec 049 M9, design `m9-hosted-workspace.md` C): the reverse proxy that serves this app's page under a public
70
+ * origin, for example `https://a1b2.213-47-80-26.sslip.io`. Only the hosting template sets it, through `ACRYL_PUBLIC_ORIGIN` and
71
+ * `ACRYL_TRUSTED_PROXY` (the proxy's exact peer addresses, comma separated). Unset, nothing changes: an app accepts its own page on loopback only.
72
+ */
73
+ export interface TrustedFront {
74
+ /** The public origin the front serves this app's page from: a scheme and a host, no path. */
75
+ readonly origin: string
76
+ /** The exact peer addresses the front connects from; no ranges, so another box on the same network is never the front. */
77
+ readonly proxies: readonly string[]
78
+ }
79
+
80
+ /** The front the environment configures, or `undefined` when it configures none or an invalid one (an invalid one is refused, never half used). */
81
+ export function trustedFrontFromEnv(env: NodeJS.ProcessEnv = process.env): TrustedFront | undefined {
82
+ const origin = env.ACRYL_PUBLIC_ORIGIN?.trim()
83
+ const proxies = (env.ACRYL_TRUSTED_PROXY ?? '').split(',').map(peer => peerAddress(peer.trim())).filter(peer => peer !== '')
84
+ if (origin === undefined || origin === '' || proxies.length === 0) return undefined
85
+ if (originOf(origin) !== origin || !/^https?:$/u.test(new URL(origin).protocol)) return undefined
86
+ if (proxies.some(peer => isIP(peer) === 0)) return undefined
87
+ return Object.freeze({ origin, proxies: Object.freeze(proxies) })
88
+ }
89
+
90
+ let configuredFront: TrustedFront | undefined | null = null
91
+
92
+ /** The front of this process, read from the environment once. */
93
+ export function trustedFront(): TrustedFront | undefined {
94
+ if (configuredFront === null) configuredFront = trustedFrontFromEnv()
95
+ return configuredFront
96
+ }
97
+
98
+ function peerAddress(address: string): string {
99
+ return (address.replace(/^\[|\]$/gu, '').split('%', 1)[0] ?? '').replace(/^::ffff:/iu, '')
100
+ }
101
+
102
+ /**
103
+ * The public authority of a request the trusted front passed on: the peer is exactly one of the front's addresses, the `Host` is exactly the
104
+ * public host, and the browser did not mark it cross-site. Anything else, or no front, gives `undefined`. The peer comes from the transport, never a
105
+ * header, so a page on another box cannot claim to be the front.
106
+ */
107
+ export function frontAuthority(c: Context, front: TrustedFront | undefined = trustedFront()): URL | undefined {
108
+ if (front === undefined) return undefined
109
+ const { remoteAddress } = connectionOf(c)
110
+ if (remoteAddress === undefined || !front.proxies.includes(peerAddress(remoteAddress))) return undefined
111
+ const publicOrigin = new URL(front.origin)
112
+ if (c.req.header('host') !== publicOrigin.host || c.req.header('sec-fetch-site') === 'cross-site') return undefined
113
+ return publicOrigin
114
+ }
115
+
116
+ /**
117
+ * The authority a browser page of this app reached it under: the loopback one, or the trusted front's public one. For routes a page calls; an
118
+ * operator route (the door's `.acryl/bin/acryl` calls) keeps {@link loopbackAuthority}, so nothing outside the box can operate the app.
119
+ */
120
+ export function pageAuthority(
121
+ c: Context,
122
+ hostnames: readonly string[] = LOOPBACK_HOSTNAMES,
123
+ front: TrustedFront | undefined = trustedFront(),
124
+ ): URL | undefined {
125
+ return loopbackAuthority(c, hostnames) ?? frontAuthority(c, front)
126
+ }
127
+
66
128
  /** Whether a changing request is acceptable by its `Origin`: absent (not a browser cross-origin change) or naming this same authority. `null` and any other origin are not. */
67
129
  export function sameOrigin(c: Context, authority: URL): boolean {
68
130
  const origin = c.req.header('origin')
69
131
  if (origin === undefined) return true
70
132
  try {
71
133
  const parsed = new URL(origin)
72
- return parsed.protocol === 'http:' && parsed.host === authority.host && parsed.pathname === '/'
134
+ return parsed.origin === authority.origin && parsed.pathname === '/' && parsed.search === ''
73
135
  } catch {
74
136
  return false
75
137
  }
@@ -106,7 +168,7 @@ export function loopbackOnly(options: LoopbackOptions = {}): MiddlewareHandler {
106
168
  await next()
107
169
  return
108
170
  }
109
- const authority = loopbackAuthority(c, options.hostnames)
171
+ const authority = pageAuthority(c, options.hostnames, 'front' in options ? options.front : trustedFront())
110
172
  if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
111
173
  if (ownPage) {
112
174
  if (!fromOwnPage(c, authority)) return c.json({ error: 'only this app\'s own page may call this' }, 403)
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { Context, MiddlewareHandler } from 'acryl-router'
15
15
  import type { UpgradeWebSocket } from 'acryl-router/helpers'
16
- import { loopbackAuthority } from 'acryl-router/middleware'
16
+ import { pageAuthority } from 'acryl-router/middleware'
17
17
  import type { ReadonlyCell } from './cell.ts'
18
18
 
19
19
  /** How often the server writes a `ping`; a page gives up on a socket that is silent for three of these. */
@@ -34,7 +34,7 @@ export interface StateSocketOptions {
34
34
 
35
35
  /** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
36
36
  export function refuseStranger(c: Context): Response | undefined {
37
- const authority = loopbackAuthority(c)
37
+ const authority = pageAuthority(c)
38
38
  if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
39
39
  const origin = c.req.header('origin')
40
40
  const site = c.req.header('sec-fetch-site')
@@ -22,7 +22,7 @@ import type { Context } from '@deepseek-ai/cordis'
22
22
  import { clientModules } from '../acryl-node-server/src/client-modules.ts'
23
23
  import { streamSSE, type UpgradeWebSocket } from '../acryl-router/src/helpers/index.ts'
24
24
  import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
25
- import { loopbackAuthority } from '../acryl-router/src/middleware/index.ts'
25
+ import { pageAuthority } from '../acryl-router/src/middleware/index.ts'
26
26
  import type {} from '../agent/index.ts'
27
27
  import type {} from '../extensions/index.ts'
28
28
  import type { ChatDriver, Driver } from './drivers.ts'
@@ -419,7 +419,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
419
419
  }
420
420
  const ws = new Acryl()
421
421
  const own: MiddlewareHandler = async (c, next) => {
422
- const authority = loopbackAuthority(c)
422
+ const authority = pageAuthority(c)
423
423
  const origin = c.req.header('origin')
424
424
  const site = c.req.header('sec-fetch-site')
425
425
  if (
@@ -8,7 +8,7 @@
8
8
  * `@deepseek-ai/cordis` still gets the bundle's, because two framework copies in one process break it (the `matchedRoutes` 500). Any other package
9
9
  * resolves as Node resolves it, from the source's own `node_modules` (what the person added), with the bundle's as the fallback.
10
10
  *
11
- * `NODE_PATH` does not reach ESM; the seam is `registerHooks`, which covers `import`, `require` and `require.resolve` alike. `bundle-overlay.ts`
11
+ * `NODE_PATH` does not reach ESM; the seam is `registerHooks`, which covers `import`, `require` and (from Node 24, the frame host's Electron Node) `require.resolve` alike. `bundle-overlay.ts`
12
12
  * registers it, first, in a product's frame host. Pure apart from `readProduct`.
13
13
  */
14
14
  import { readFileSync, realpathSync } from 'node:fs'
@@ -218,8 +218,17 @@ export type MigrationResult =
218
218
  export function migrateOldHome(
219
219
  appRoot: string,
220
220
  dataHome: string,
221
- copy: (from: string, to: string) => void = (from, to) =>
222
- cpSync(from, to, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true }),
221
+ copy: (from: string, to: string) => void = (from, to) => {
222
+ // Entry by entry into the empty data folder: on Linux cpSync refuses a target folder that exists (EEXIST) even when it is empty, while
223
+ // nothing inside may ever be overwritten either way.
224
+ for (const entry of readdirSync(from))
225
+ cpSync(join(from, entry), join(to, entry), {
226
+ recursive: true,
227
+ errorOnExist: true,
228
+ force: false,
229
+ verbatimSymlinks: true,
230
+ })
231
+ },
223
232
  ): MigrationResult {
224
233
  const old = join(appRoot, OLD_HOME_DIR)
225
234
  if (!existsSync(old) || !lstatSync(old).isDirectory()) return { status: 'none' }