acrylblends 1.5.1 → 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.
package/legacy/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # The scaffolds published acrylblends releases shipped
2
2
 
3
3
  Generated by `scripts/build-legacy-layouts.mjs`; do not edit by hand. Each folder is a template, each hash under it one distinct scaffold (deduplicated by
4
- content hash across releases), and `layouts.json` maps every published release that shipped templates (0.3.0 through 1.5.0, 46 releases)
4
+ content hash across releases), and `layouts.json` maps every published release that shipped templates (0.3.0 through 1.5.1, 47 releases)
5
5
  to the layouts it shipped.
6
6
 
7
7
  `acrylblends upgrade` compares an app's scaffold files with these bytes: a file the app has not edited is brought up to date, an edited one is refused (or
@@ -452,6 +452,17 @@
452
452
  "notes": "fb43a1c5f3b4",
453
453
  "todo": "164274ffcf7f"
454
454
  }
455
+ },
456
+ {
457
+ "version": "1.5.1",
458
+ "layouts": {
459
+ "agent": "46c6eadf9230",
460
+ "blank": "a53d41e6ef42",
461
+ "chat": "d4464dfb3b04",
462
+ "nodejs": "a6a340b4d3a6",
463
+ "notes": "fb43a1c5f3b4",
464
+ "todo": "164274ffcf7f"
465
+ }
455
466
  }
456
467
  ],
457
468
  "templates": {
@@ -1,12 +1,12 @@
1
1
  import { t as Acryl } from "../app-CQcGWjrk.js";
2
2
  import { r as streamSSE } from "../streaming-BlDaph8U.js";
3
- import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
3
+ import { o as pageAuthority } from "../loopback-only-BoBCuDv7.js";
4
4
  import "../helpers-BEjcdbgw.js";
5
5
  import { n as clientModules } from "../client-modules-C5qiVroz.js";
6
6
  import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
7
- import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-CXTnjxGx.js";
7
+ import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-B9PWQoX6.js";
8
8
  import "../src-iBJ2ysV9.js";
9
- import "../middleware-BfG_fp8P.js";
9
+ import "../middleware-Do9XNe0Y.js";
10
10
  import { isAbsolute, join, relative, resolve } from "node:path";
11
11
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
12
  import z from "@deepseek-ai/schemastery";
@@ -1355,9 +1355,9 @@ function operatorRoutes(prefix, secret, door, reportError) {
1355
1355
  }
1356
1356
  });
1357
1357
  }
1358
- /** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
1358
+ /** The upgrade request comes from this app's own page: a loopback peer on this port (or the hosted app's trusted front), and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
1359
1359
  const ownOrigin = async (c, next) => {
1360
- const authority = loopbackAuthority(c);
1360
+ const authority = pageAuthority(c);
1361
1361
  const origin = c.req.header("origin");
1362
1362
  const site = c.req.header("sec-fetch-site");
1363
1363
  if (authority === void 0 || origin === void 0 || origin !== authority.origin || site !== void 0 && site !== "same-origin") return c.json(error("only this app's own page may open the channel"), 403);
@@ -1,7 +1,7 @@
1
1
  import { t as Acryl } from "../app-CQcGWjrk.js";
2
- import { i as loopbackOnly } from "../loopback-only-BbasoF7R.js";
2
+ import { a as loopbackOnly } from "../loopback-only-BoBCuDv7.js";
3
3
  import "../src-iBJ2ysV9.js";
4
- import "../middleware-BfG_fp8P.js";
4
+ import "../middleware-Do9XNe0Y.js";
5
5
  import { Service } from "@deepseek-ai/cordis";
6
6
  //#region ../../plugins/acryl-inspect/src/report.ts
7
7
  /**
@@ -1,10 +1,10 @@
1
1
  import { t as Acryl } from "../app-CQcGWjrk.js";
2
2
  import { t as methodNotAllowed } from "../method-not-allowed-TO2_hnfl.js";
3
3
  import { r as streamSSE } from "../streaming-BlDaph8U.js";
4
- import { i as loopbackOnly, r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
4
+ import { a as loopbackOnly, o as pageAuthority } from "../loopback-only-BoBCuDv7.js";
5
5
  import "../helpers-BEjcdbgw.js";
6
6
  import "../src-iBJ2ysV9.js";
7
- import "../middleware-BfG_fp8P.js";
7
+ import "../middleware-Do9XNe0Y.js";
8
8
  import { Service } from "@deepseek-ai/cordis";
9
9
  //#region ../../plugins/acryl-state/src/cell.ts
10
10
  /** A writable cell. `report` receives anything a listener throws; one failing listener never blocks the others. */
@@ -305,7 +305,7 @@ export function watch(key, onValue, onError) {
305
305
  `;
306
306
  /** 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. */
307
307
  function refuseStranger(c) {
308
- const authority = loopbackAuthority(c);
308
+ const authority = pageAuthority(c);
309
309
  if (authority === void 0) return c.json({ error: "request authority rejected" }, 403);
310
310
  const origin = c.req.header("origin");
311
311
  const site = c.req.header("sec-fetch-site");
package/lib/door/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-CXTnjxGx.js";
1
+ import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-B9PWQoX6.js";
2
2
  export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
@@ -23,7 +23,7 @@ function loopbackAuthority(c, hostnames = LOOPBACK_HOSTNAMES) {
23
23
  const { remoteAddress, localPort } = connectionOf(c);
24
24
  const host = c.req.header("host");
25
25
  if (remoteAddress === void 0 || localPort === void 0 || host === void 0) return void 0;
26
- const address = (remoteAddress.replace(/^\[|\]$/gu, "").split("%", 1)[0] ?? "").replace(/^::ffff:/iu, "");
26
+ const address = peerAddress(remoteAddress);
27
27
  const family = isIP(address);
28
28
  if (family === 0 || !loopback.check(address, family === 4 ? "ipv4" : "ipv6")) return void 0;
29
29
  let authority;
@@ -35,13 +35,54 @@ function loopbackAuthority(c, hostnames = LOOPBACK_HOSTNAMES) {
35
35
  if (authority.protocol !== "http:" || Number(authority.port || "80") !== localPort || !hostnames.includes(authority.hostname) || c.req.header("sec-fetch-site") === "cross-site") return void 0;
36
36
  return authority;
37
37
  }
38
+ /** The front the environment configures, or `undefined` when it configures none or an invalid one (an invalid one is refused, never half used). */
39
+ function trustedFrontFromEnv(env = process.env) {
40
+ const origin = env.ACRYL_PUBLIC_ORIGIN?.trim();
41
+ const proxies = (env.ACRYL_TRUSTED_PROXY ?? "").split(",").map((peer) => peerAddress(peer.trim())).filter((peer) => peer !== "");
42
+ if (origin === void 0 || origin === "" || proxies.length === 0) return void 0;
43
+ if (originOf(origin) !== origin || !/^https?:$/u.test(new URL(origin).protocol)) return void 0;
44
+ if (proxies.some((peer) => isIP(peer) === 0)) return void 0;
45
+ return Object.freeze({
46
+ origin,
47
+ proxies: Object.freeze(proxies)
48
+ });
49
+ }
50
+ let configuredFront = null;
51
+ /** The front of this process, read from the environment once. */
52
+ function trustedFront() {
53
+ if (configuredFront === null) configuredFront = trustedFrontFromEnv();
54
+ return configuredFront;
55
+ }
56
+ function peerAddress(address) {
57
+ return (address.replace(/^\[|\]$/gu, "").split("%", 1)[0] ?? "").replace(/^::ffff:/iu, "");
58
+ }
59
+ /**
60
+ * 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
61
+ * 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
62
+ * header, so a page on another box cannot claim to be the front.
63
+ */
64
+ function frontAuthority(c, front = trustedFront()) {
65
+ if (front === void 0) return void 0;
66
+ const { remoteAddress } = connectionOf(c);
67
+ if (remoteAddress === void 0 || !front.proxies.includes(peerAddress(remoteAddress))) return void 0;
68
+ const publicOrigin = new URL(front.origin);
69
+ if (c.req.header("host") !== publicOrigin.host || c.req.header("sec-fetch-site") === "cross-site") return void 0;
70
+ return publicOrigin;
71
+ }
72
+ /**
73
+ * 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
74
+ * operator route (the door's `.acryl/bin/acryl` calls) keeps {@link loopbackAuthority}, so nothing outside the box can operate the app.
75
+ */
76
+ function pageAuthority(c, hostnames = LOOPBACK_HOSTNAMES, front = trustedFront()) {
77
+ return loopbackAuthority(c, hostnames) ?? frontAuthority(c, front);
78
+ }
38
79
  /** 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. */
39
80
  function sameOrigin(c, authority) {
40
81
  const origin = c.req.header("origin");
41
82
  if (origin === void 0) return true;
42
83
  try {
43
84
  const parsed = new URL(origin);
44
- return parsed.protocol === "http:" && parsed.host === authority.host && parsed.pathname === "/";
85
+ return parsed.origin === authority.origin && parsed.pathname === "/" && parsed.search === "";
45
86
  } catch {
46
87
  return false;
47
88
  }
@@ -75,7 +116,7 @@ function loopbackOnly(options = {}) {
75
116
  await next();
76
117
  return;
77
118
  }
78
- const authority = loopbackAuthority(c, options.hostnames);
119
+ const authority = pageAuthority(c, options.hostnames, "front" in options ? options.front : trustedFront());
79
120
  if (authority === void 0) return c.json({ error: "request authority rejected" }, 403);
80
121
  if (ownPage) {
81
122
  if (!fromOwnPage(c, authority)) return c.json({ error: "only this app's own page may call this" }, 403);
@@ -87,4 +128,4 @@ function loopbackOnly(options = {}) {
87
128
  };
88
129
  }
89
130
  //#endregion
90
- export { sameOrigin as a, loopbackOnly as i, fromOwnPage as n, loopbackAuthority as r, LOOPBACK_HOSTNAMES as t };
131
+ export { loopbackOnly as a, trustedFront as c, loopbackAuthority as i, trustedFrontFromEnv as l, fromOwnPage as n, pageAuthority as o, frontAuthority as r, sameOrigin as s, LOOPBACK_HOSTNAMES as t };
@@ -24,6 +24,8 @@ interface LoopbackOptions {
24
24
  * same-origin` and a `Referer` from this authority. `mutations` is not consulted: every request is held to that rule.
25
25
  */
26
26
  readonly callers?: 'local' | 'own-page';
27
+ /** The hosted app's trusted front; default: the one the environment configures ({@link trustedFront}), usually none. Tests pass one. */
28
+ readonly front?: TrustedFront | undefined;
27
29
  }
28
30
  /**
29
31
  * The authority a loopback request addressed: the `Host` header as a URL, or `undefined` when the request does not come from a loopback peer to the
@@ -32,6 +34,32 @@ interface LoopbackOptions {
32
34
  */
33
35
  declare const LOOPBACK_HOSTNAMES: readonly string[];
34
36
  declare function loopbackAuthority(c: Context, hostnames?: readonly string[]): URL | undefined;
37
+ /**
38
+ * 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
39
+ * origin, for example `https://a1b2.213-47-80-26.sslip.io`. Only the hosting template sets it, through `ACRYL_PUBLIC_ORIGIN` and
40
+ * `ACRYL_TRUSTED_PROXY` (the proxy's exact peer addresses, comma separated). Unset, nothing changes: an app accepts its own page on loopback only.
41
+ */
42
+ interface TrustedFront {
43
+ /** The public origin the front serves this app's page from: a scheme and a host, no path. */
44
+ readonly origin: string;
45
+ /** The exact peer addresses the front connects from; no ranges, so another box on the same network is never the front. */
46
+ readonly proxies: readonly string[];
47
+ }
48
+ /** The front the environment configures, or `undefined` when it configures none or an invalid one (an invalid one is refused, never half used). */
49
+ declare function trustedFrontFromEnv(env?: NodeJS.ProcessEnv): TrustedFront | undefined;
50
+ /** The front of this process, read from the environment once. */
51
+ declare function trustedFront(): TrustedFront | undefined;
52
+ /**
53
+ * 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
54
+ * 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
55
+ * header, so a page on another box cannot claim to be the front.
56
+ */
57
+ declare function frontAuthority(c: Context, front?: TrustedFront | undefined): URL | undefined;
58
+ /**
59
+ * 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
60
+ * operator route (the door's `.acryl/bin/acryl` calls) keeps {@link loopbackAuthority}, so nothing outside the box can operate the app.
61
+ */
62
+ declare function pageAuthority(c: Context, hostnames?: readonly string[], front?: TrustedFront | undefined): URL | undefined;
35
63
  /** 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. */
36
64
  declare function sameOrigin(c: Context, authority: URL): boolean;
37
65
  /** Whether the request comes from a page served by this authority itself: the `own-page` rule of {@link LoopbackOptions.callers}. */
@@ -42,4 +70,4 @@ declare function fromOwnPage(c: Context, authority: URL): boolean;
42
70
  */
43
71
  declare function loopbackOnly(options?: LoopbackOptions): MiddlewareHandler;
44
72
  //#endregion
45
- export { LOOPBACK_HOSTNAMES, LoopbackOptions, fromOwnPage, loopbackAuthority, loopbackOnly, sameOrigin };
73
+ export { LOOPBACK_HOSTNAMES, LoopbackOptions, TrustedFront, fromOwnPage, frontAuthority, loopbackAuthority, loopbackOnly, pageAuthority, sameOrigin, trustedFront, trustedFrontFromEnv };
@@ -1,2 +1,2 @@
1
- import { a as sameOrigin, i as loopbackOnly, n as fromOwnPage, r as loopbackAuthority, t as LOOPBACK_HOSTNAMES } from "./loopback-only-BbasoF7R.js";
2
- export { LOOPBACK_HOSTNAMES, fromOwnPage, loopbackAuthority, loopbackOnly, sameOrigin };
1
+ import { a as loopbackOnly, c as trustedFront, i as loopbackAuthority, l as trustedFrontFromEnv, n as fromOwnPage, o as pageAuthority, r as frontAuthority, s as sameOrigin, t as LOOPBACK_HOSTNAMES } from "./loopback-only-BoBCuDv7.js";
2
+ export { LOOPBACK_HOSTNAMES, fromOwnPage, frontAuthority, loopbackAuthority, loopbackOnly, pageAuthority, sameOrigin, trustedFront, trustedFrontFromEnv };
@@ -8,5 +8,5 @@ import "./jwt-lyS4Q3mT.js";
8
8
  import "./method-not-allowed-TO2_hnfl.js";
9
9
  import "./serve-static-EiJf1zkO.js";
10
10
  import "./timeout-FeqZ9A-0.js";
11
- import "./loopback-only-BbasoF7R.js";
11
+ import "./loopback-only-BoBCuDv7.js";
12
12
  export {};
@@ -2,11 +2,11 @@ import { t as Acryl } from "./app-CQcGWjrk.js";
2
2
  import { t as bodyLimit } from "./body-limit-WmIjWRLt.js";
3
3
  import { t as methodNotAllowed } from "./method-not-allowed-TO2_hnfl.js";
4
4
  import { r as streamSSE } from "./streaming-BlDaph8U.js";
5
- import { i as loopbackOnly, r as loopbackAuthority } from "./loopback-only-BbasoF7R.js";
5
+ import { a as loopbackOnly, i as loopbackAuthority } from "./loopback-only-BoBCuDv7.js";
6
6
  import "./helpers-BEjcdbgw.js";
7
7
  import { d as parseChannelToHost, l as UiControlError, u as WINDOW_ID } from "./protocol-CQ_if7Dg.js";
8
8
  import "./src-iBJ2ysV9.js";
9
- import "./middleware-BfG_fp8P.js";
9
+ import "./middleware-Do9XNe0Y.js";
10
10
  import { dirname, join } from "node:path";
11
11
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
12
12
  import { timingSafeEqual } from "node:crypto";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { Acryl, type MiddlewareHandler } from 'acryl-router'
12
12
  import type { UpgradeWebSocket, WSEvents } from 'acryl-router/helpers'
13
- import { loopbackAuthority } from 'acryl-router/middleware'
13
+ import { pageAuthority } from 'acryl-router/middleware'
14
14
  import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
15
15
  import type { UiChannel } from './channel.ts'
16
16
  import { error } from './http.ts'
@@ -25,9 +25,9 @@ export interface PageSocketOptions {
25
25
  readonly heartbeatMs?: number
26
26
  }
27
27
 
28
- /** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
28
+ /** The upgrade request comes from this app's own page: a loopback peer on this port (or the hosted app's trusted front), and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
29
29
  export const ownOrigin: MiddlewareHandler = async (c, next) => {
30
- const authority = loopbackAuthority(c)
30
+ const authority = pageAuthority(c)
31
31
  const origin = c.req.header('origin')
32
32
  const site = c.req.header('sec-fetch-site')
33
33
  if (authority === undefined || origin === undefined || origin !== authority.origin || (site !== undefined && site !== 'same-origin')) {
@@ -25,4 +25,4 @@ export { NONCE, secureHeaders, type SecureHeadersVariables } from '../core/middl
25
25
  export { timeout } from '../core/middleware/timeout/index.ts'
26
26
  export { endTime, setMetric, startTime, timing, wrapTime, type TimingVariables } from '../core/middleware/timing/index.ts'
27
27
  export { appendTrailingSlash, trimTrailingSlash } from '../core/middleware/trailing-slash/index.ts'
28
- export { LOOPBACK_HOSTNAMES, fromOwnPage, loopbackAuthority, loopbackOnly, sameOrigin, type LoopbackOptions } from './loopback-only.ts'
28
+ export { LOOPBACK_HOSTNAMES, frontAuthority, fromOwnPage, loopbackAuthority, loopbackOnly, pageAuthority, sameOrigin, trustedFront, trustedFrontFromEnv, type LoopbackOptions, type TrustedFront } from './loopback-only.ts'
@@ -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 (