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.
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.4.0, 45 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
@@ -0,0 +1,160 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as door from '../plugins/acryl-door/src/index.ts'
5
+ import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
6
+ import * as router from '../plugins/acryl-router/src/index.ts'
7
+ import { extensions } from '../plugins/extensions/index.ts'
8
+ import * as home from '../plugins/home/index.ts'
9
+ import { type AppInstance, instance } from '../plugins/instance/index.ts'
10
+ import {
11
+ appPluginNames,
12
+ blueprintsIn,
13
+ compose,
14
+ loadAppPlugins,
15
+ loadManifest,
16
+ type ParameterValue,
17
+ } from '../plugins/manifest/index.ts'
18
+ import { pluginShare } from '../plugins/plugin-share/index.ts'
19
+ import { doorEnabled } from './door.ts'
20
+ import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
21
+
22
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them, and adds the app's own plugins from `plugins/` by name. */
23
+ export const registry = {
24
+ 'acryl-router': router,
25
+ 'acryl-node-server': nodeServer,
26
+ 'acryl-door': door,
27
+ home,
28
+ extensions,
29
+ 'plugin-share': pluginShare,
30
+ routes,
31
+ }
32
+
33
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
34
+
35
+ /** The manifest next to this app's source. */
36
+ export const MANIFEST = join(projectRoot, 'blend.yaml')
37
+ /** The app's plugins: the library copies `registry` imports, and the app's own plugins (a feature each), which `blend.yaml` mounts by their folder name. */
38
+ const PLUGINS_DIR = join(projectRoot, 'plugins')
39
+
40
+ /**
41
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
42
+ * `createApp` and the tests read it the same way.
43
+ */
44
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
45
+ return loadManifest(MANIFEST, {
46
+ known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
47
+ getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
48
+ ...(values ? { values } : {}),
49
+ })
50
+ }
51
+
52
+ /** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
53
+ function without<M extends { plugins: readonly { use: string }[] }>(
54
+ manifest: M,
55
+ ...uses: string[]
56
+ ): M {
57
+ return {
58
+ ...manifest,
59
+ plugins: manifest.plugins.map((row) =>
60
+ uses.includes(row.use) ? { ...row, disabled: true } : row,
61
+ ),
62
+ }
63
+ }
64
+
65
+ export interface App {
66
+ readonly root: Context
67
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
68
+ readonly url: string
69
+ stop(): Promise<void>
70
+ }
71
+
72
+ export interface AppOptions {
73
+ readonly values?: Readonly<Record<string, ParameterValue>>
74
+ /** The path the app is mounted at; absent on its own server, where it keeps the root, and {@link FRAME_MOUNT_PREFIX} in the frame. */
75
+ readonly prefix?: string
76
+ /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
77
+ readonly listen?: boolean
78
+ }
79
+
80
+ /** The rows the frame host provides: the app contributes only the plugins that are its own. */
81
+ const HOST_ROWS = [
82
+ 'instance',
83
+ 'acryl-router',
84
+ 'acryl-node-server',
85
+ 'acryl-web-server',
86
+ 'acryl-page',
87
+ 'manifest',
88
+ 'save',
89
+ 'acryl-door',
90
+ ]
91
+
92
+ /**
93
+ * The app's rows, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it as one row, so the frame
94
+ * runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the infrastructure rows to the frame host,
95
+ * which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}.
96
+ */
97
+ export async function composeInto(
98
+ ctx: Context,
99
+ chosen: AppInstance,
100
+ options: AppOptions & { readonly host?: boolean } = {},
101
+ ): Promise<Fiber[]> {
102
+ const read = readManifest(options.values)
103
+ const own = options.host !== true && options.listen !== false
104
+ // The frame host mounts the app under its own prefix; the app's own server keeps the root.
105
+ const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
106
+ // Rows left out: the server (and the door, which needs it) when the app is reached in process or inside the frame host; the door when it is switched
107
+ // off.
108
+ const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
109
+ if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
110
+ if (!doorEnabled()) leaveOut.add('acryl-door')
111
+ // A pull runs a stranger's code and a publish leaves the machine: development only, like the door.
112
+ if (process.env.NODE_ENV === 'production') leaveOut.add('plugin-share')
113
+ const manifest = without(read, ...leaveOut)
114
+ // The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import. Each one that runs
115
+ // is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
116
+ const ownRows = [
117
+ ...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
118
+ ]
119
+ const ownPlugins = await loadAppPlugins(
120
+ PLUGINS_DIR,
121
+ ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
122
+ )
123
+ return compose(
124
+ ctx,
125
+ manifest,
126
+ { ...registry, ...ownPlugins },
127
+ {
128
+ // The routes row mounts the pages (and the client modules) at the prefix the frame host chose.
129
+ routes: { ...(prefix === undefined ? {} : { prefix }) },
130
+ // The app's own plugins live in plugins/, next to the plugins copied from the library (plugins/library.json lists those, so they are not installed twice).
131
+ // In production they start read-only from where they are deployed: the app keeps its features, and nothing can be installed while it runs.
132
+ extensions: {
133
+ ...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
134
+ sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
135
+ rows: ownRows,
136
+ // Messages name a route as the plugin wrote it, not under the frame's canvas prefix.
137
+ ...(prefix === undefined ? {} : { routePrefix: prefix }),
138
+ },
139
+ // Pulled plugins go where the extensions row installs from.
140
+ 'plugin-share': { dir: PLUGINS_DIR },
141
+ // Only the app's own server listens; the frame host owns the server and its port.
142
+ ...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
143
+ },
144
+ )
145
+ }
146
+
147
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
148
+ export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
149
+ const root = new Context()
150
+ const first = root.plugin(instance, chosen)
151
+ await first.await()
152
+ const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
153
+ return {
154
+ root,
155
+ url: options.listen === false ? '' : root.nodeServer.url,
156
+ async stop() {
157
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
158
+ },
159
+ }
160
+ }
@@ -0,0 +1,79 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { Acryl } from '../plugins/acryl-router/src/index.ts'
3
+ import { type Home, renderWidgets } from '../plugins/home/index.ts'
4
+ import { aboutPage } from './about.tsx'
5
+ import { FIRST_PAGE_MARKER } from './first-page.ts'
6
+
7
+ export const APP_NAME = '__APP_NAME__'
8
+
9
+ /**
10
+ * The only place the app's mount prefix is named. On its own server (production, `npm start`) the app mounts at the root and
11
+ * its public URLs are `/`, `/about`, `/health`; when the ACRYL frame hosts it (`npm run dev`) the routes mount here, and the
12
+ * frame opens its tile at `/app` (`/app/` answers too). Every page link and asset URL this template writes is derived from the prefix it is
13
+ * mounted at, so the same source serves both.
14
+ */
15
+ export const FRAME_MOUNT_PREFIX = '/app'
16
+
17
+ /** The scaffold's name comes from the folder, so the first screen escapes it like any other user text. */
18
+ const escapeHtml = (text: string): string =>
19
+ text
20
+ .replaceAll('&', '&amp;')
21
+ .replaceAll('<', '&lt;')
22
+ .replaceAll('>', '&gt;')
23
+ .replaceAll('"', '&quot;')
24
+
25
+ /**
26
+ * The first screen: the page a browser gets at the root, which is what the ACRYL frame's first tile shows. A blank app whose root answered JSON to
27
+ * every client gave the tile Chrome's JSON viewer, nearly invisible in the frame's dark surface (owner review, 2026-11-02); the root now answers a page
28
+ * to a browser and the JSON hello to everything else, the way the agent template does. The About link is relative, so it works under the frame's /app
29
+ * mount and on the app's own server alike.
30
+ */
31
+ const homePage = (name: string, widgets: string): string => `<!doctype html>
32
+ <html lang="en">
33
+ <head>
34
+ <meta charset="utf-8">
35
+ <meta name="viewport" content="width=device-width, initial-scale=1">
36
+ <title>${escapeHtml(name)}</title>
37
+ <style>:root{color-scheme:light dark;background:Canvas;color:CanvasText}body{margin:2rem auto;max-width:44rem;padding:0 1rem;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}h1{font-size:1.6rem;margin:0 0 .5rem}a{color:#5468ff}code{font:.9em ui-monospace,"SF Mono",Menlo,monospace}</style>
38
+ </head>
39
+ <body>
40
+ <main>
41
+ <h1>${escapeHtml(name)}</h1>
42
+ <p>Your app is running. ${FIRST_PAGE_MARKER}; edit <code>src/routes.ts</code> to change it.</p>
43
+ <p><a href="./about">About</a></p>
44
+ ${widgets}
45
+ </main>
46
+ </body>
47
+ </html>`
48
+
49
+ /**
50
+ * The app's own pages and routes, mountable at any prefix: the standalone server mounts it at the root, the frame host at {@link FRAME_MOUNT_PREFIX}.
51
+ * The first screen shows the widgets of the app's plugins (`ctx.home`), read at each request, so a plugin switched on or off shows at the next load.
52
+ */
53
+ export function makeApp(home: Home, name: () => string = () => APP_NAME): Acryl {
54
+ return (
55
+ new Acryl()
56
+ // A browser gets the first screen; an API client gets the JSON hello.
57
+ .get('/', async (c) =>
58
+ (c.req.header('accept') ?? '').includes('text/html')
59
+ ? c.html(homePage(name(), await renderWidgets(home)))
60
+ : c.json({ app: name(), ok: true }),
61
+ )
62
+ .get('/health', (c) => c.json({ ok: true }))
63
+ .get('/about', (c) => c.html(aboutPage()))
64
+ )
65
+ }
66
+
67
+ /** The app's own routes: an `App` mounted on the router at `options.prefix` (empty or absent: the root). The mount is removed when this plugin is disposed. */
68
+ export const routes = {
69
+ name: 'routes',
70
+ inject: ['router', 'home'],
71
+ apply(ctx: Context, options: { readonly prefix?: string } = {}) {
72
+ const prefix = options.prefix ?? ''
73
+ // The name the app has now (Settings > App renames it), else the one the scaffold was made with.
74
+ ctx.router.route(
75
+ prefix === '' ? '/' : prefix,
76
+ makeApp(ctx.home, () => ctx.get('appInstance')?.name ?? APP_NAME),
77
+ )
78
+ },
79
+ }
@@ -441,6 +441,28 @@
441
441
  "notes": "fb43a1c5f3b4",
442
442
  "todo": "164274ffcf7f"
443
443
  }
444
+ },
445
+ {
446
+ "version": "1.5.0",
447
+ "layouts": {
448
+ "agent": "46c6eadf9230",
449
+ "blank": "a53d41e6ef42",
450
+ "chat": "d4464dfb3b04",
451
+ "nodejs": "a6a340b4d3a6",
452
+ "notes": "fb43a1c5f3b4",
453
+ "todo": "164274ffcf7f"
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
+ }
444
466
  }
445
467
  ],
446
468
  "templates": {
@@ -534,6 +556,10 @@
534
556
  "a7e8f539ffee": [
535
557
  "src/app.ts",
536
558
  "src/routes.ts"
559
+ ],
560
+ "a53d41e6ef42": [
561
+ "src/app.ts",
562
+ "src/routes.ts"
537
563
  ]
538
564
  },
539
565
  "chat": {
@@ -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.0",
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'