acrylblends 1.0.1 → 1.0.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.
@@ -33,6 +33,24 @@ The `agent` template (a Cordis app; `acryl-app` is its old name and still works)
33
33
 
34
34
  See [Acryl in a Cordis app](../cordis/acryl-in-a-cordis-app.md), [Plugin lifecycle](../cordis/plugin-lifecycle.md) and [Mounting](../cordis/mounting.md).
35
35
 
36
+ ## The agent panel and the terminals
37
+
38
+ The panel is a chat button on every page of the running app, and `driver-pty` adds real terminals to it. Both are **development only** (see [what runs in production](../guides/production.md)). They need the rows of the `agent` template (`agent` and `extensions`, with the `model`, `tools` and `memory` the agent brings), so start from that template:
39
+
40
+ ```sh
41
+ npm create acrylblends@latest my-app agent
42
+ cd my-app
43
+ npm install
44
+ npx acrylblends add agent-panel driver-pty
45
+ npm install
46
+ npm run dev
47
+ ```
48
+
49
+ - `add` puts the `agent-panel` row (and `driver-pty`'s after it) in `blend.yaml` before the `routes` row and registers both in `src/app.ts`. On an app without the `agent` and `extensions` rows, such as `blank`, it refuses before it copies anything and says which rows are missing.
50
+ - **The panel's coding agents are yours.** `claude`, `codex` and `pi` run on the machine that runs the app, in the app folder, with the login of the person who started it: `claude` and `codex` use their own signed-in sessions, and `pi` is offered when `DEEPSEEK_API_KEY` is set or `pi` has stored a DeepSeek login (`~/.pi/agent/auth.json`). A program that is not installed, or is not logged in, is not offered; nothing is shared between machines, and a workshop laptop needs its own login. The panel's own `agent` (the offline scripted one) needs none.
51
+ - `pi` has to be an executable on the `PATH`; a shell function that wraps the binary is fine as long as the binary itself is found by `which pi`.
52
+ - `driver-pty` brings `node-pty`, which ships prebuilt binaries for macOS, Windows and Linux. npm 11 and pnpm warn that its install script was not run (they ask you to approve dependency install scripts); you can ignore the warning. If no `pty:` terminal is offered and the app prints why at start, approve and rebuild: `npm approve-scripts node-pty`, then `npm rebuild node-pty` (pnpm: `pnpm approve-builds`).
53
+
36
54
  ## Checking that an agent can use the docs
37
55
 
38
56
  The repository has a manual gate: it creates a fresh app, starts a headless coding agent inside it with a one-line task (for example "add a /customers/count route with a test"),
@@ -7,12 +7,13 @@
7
7
  An app made by `acrylblends init` carries copies of the framework plugins (`acryl-router`, `acryl-node-server`, `acryl-door`, `extensions`, ...) in `plugins/`. `acrylblends upgrade` brings them to the version of the installed package:
8
8
 
9
9
  ```sh
10
- npm install acrylblends@latest # in an app that depends on the package; the agent template does not
11
- npx acrylblends upgrade --dry-run # what would change, and the diff of every file you edited
12
- npx acrylblends upgrade # do it
10
+ npx acrylblends@latest upgrade --dry-run # what would change, and the diff of every file you edited
11
+ npx acrylblends@latest upgrade # do it
13
12
  npm install && npm run check && npm test
14
13
  ```
15
14
 
15
+ **Write `@latest`.** A bare `npx acrylblends upgrade` runs the `acrylblends` the app has installed, and an app made before 1.0 has a 0.x copy with no `upgrade` verb. It does not upgrade anything: depending on the platform and the version it prints its old help and exits 0 (so nothing happens and nothing says so), or answers "unknown command" and exits 2. `npx acrylblends@latest` fetches the current package and runs that. (After the first upgrade the app depends on 1.x, and the bare form works too.)
16
+
16
17
  - A file nobody edited is replaced. A file the app edited stops the upgrade: nothing is changed, the diff of what would be lost is shown, and the command exits 1. Move your change out of the plugin (a plugin of your own in `plugins/<name>/` is never touched), or run `npx acrylblends upgrade --force`, which saves the edited versions in `.acrylblends/upgrade-backup/<time>/` before replacing them.
17
18
  - `--diff` prints the diff of every changed file, not only the edited ones. `acrylblends upgrade acryl-router extensions` upgrades only those plugins.
18
19
  - A file of your own inside a library plugin folder (a note, a test) is kept and reported. A plugin that a newer version needs and the app lacks is added.
@@ -36,6 +36,26 @@ In a Blend app, the same two commands (`npx acrylblends add desktop`, `npm insta
36
36
  `npm install` does not download Electron's binary (Electron 44 has no install script). The first `npm run desktop` does: about 100 MB, once per machine, then it is cached. Behind a proxy or mirror set `ELECTRON_MIRROR`
37
37
  (and `ELECTRON_CACHE` to reuse a download); if the download is blocked, run `npx install-electron` where the mirror is reachable.
38
38
 
39
+ ## Linux: Ubuntu 23.10 and later
40
+
41
+ Ubuntu 23.10 and later restrict unprivileged user namespaces (`kernel.apparmor_restrict_unprivileged_userns=1`). Chromium's sandbox needs them, and an Electron unpacked by npm into `node_modules` has no AppArmor profile, so Electron stops at the start with "The SUID sandbox helper binary was found, but is not configured correctly" before the app runs.
42
+
43
+ `npm run desktop` starts through `plugins/desktop/launch.ts`, which reads that sysctl. **Only when it is `1` on Linux** it starts Electron with `--no-sandbox` and prints one line saying so; on macOS, on Windows and on any Linux without the restriction Electron starts with its sandbox, as it always does. The window's renderer is still locked down (context isolation, no Node in the page, web security); what is lost is Chromium's process sandbox around it.
44
+
45
+ To keep the sandbox, give Electron an AppArmor profile that allows `userns`, as the VS Code and Chrome `.deb` packages do. Save this as `/etc/apparmor.d/my-app-electron` (the path is the one in your app's `node_modules`), then `sudo apparmor_parser -r /etc/apparmor.d/my-app-electron`:
46
+
47
+ ```text
48
+ abi <abi/4.0>,
49
+ include <tunables/global>
50
+
51
+ profile my-app-electron /path/to/my-app/node_modules/electron/dist/electron flags=(unconfined) {
52
+ userns,
53
+ include if exists <local/my-app-electron>
54
+ }
55
+ ```
56
+
57
+ The launcher then still sees the sysctl as `1` and passes `--no-sandbox`; set `ACRYLBLENDS_DESKTOP_SANDBOX=1` to start with the sandbox anyway.
58
+
39
59
  ## A different start page
40
60
 
41
61
  The window opens on `/`. To open another path, set it in `package.json`: `"acrylblends": { "desktopPath": "/todo" }`.
@@ -54,6 +54,8 @@ bun create acrylblends@latest my-app --template nodejs
54
54
  | `[directory]` | The folder to create. It must not exist or must be empty. Asked for when it is missing. | `my-app` |
55
55
  | `[source]` | A template from outside the package: `github:owner/repo[#ref]`, `@scope/name[@version]` or a packed `.tgz` file. See [Templates from a repository or a package](#templates-from-a-repository-or-a-package). | `github:acme/starter` |
56
56
  | `--template <template>` | One of `blank`, `nodejs`, `notes`, `chat`, `todo`, `agent` (the same list as `acrylblends init --template`; `acryl-app` is accepted as `agent`). Skips the template question; a bare name after the directory means the same. | `--template notes` |
57
+ | `--from <id \| source>` | A starter from the registry by its id (`acrylblends.notes-desktop`), or any source above. Only starters that declare `runtime: acrylblends` are made; an ACRYL app starter is refused. See [Starters from the registry](#starters-from-the-registry). | `-- --from acrylblends.notes-desktop` |
58
+ | `--registry <source>` | Read the ids from this registry instead of the public one: `github:owner/repo` or a folder, with an `index.json` (at its root or in `registry/`). | `-- --from login.thing --registry github:acryldev/workshop-2026-11-20` |
57
59
  | `--install` | Install the dependencies with the package manager after creating the project. For a template from a source, the install scripts it will run are listed first. | `--install` |
58
60
  | `--pm <packageManager>` | The package manager for `--install`: `npm`, `pnpm`, `yarn` or `bun`. Defaults to the one that started the initializer. | `--pm pnpm` |
59
61
  | `--yes`, `-y` | Ask nothing: use `nodejs` unless `--template` is given, and the directory `acryl-app`. | `-y` |
@@ -61,6 +63,25 @@ bun create acrylblends@latest my-app --template nodejs
61
63
 
62
64
  The templates are inside the package, so creating a project needs no network except for `--install`.
63
65
 
66
+ ## Starters from the registry
67
+
68
+ The [registry](https://acrylblends.github.io/blends) lists starters people published. Each is a whole app (`blend.yaml`, `plugins/`, `src/`, `package.json`, `README.md`) that other people can start from, and its index says which runtime it is for:
69
+
70
+ ```sh
71
+ npm create acrylblends@latest my-notes -- --from acrylblends.notes-desktop
72
+ cd my-notes
73
+ npm install
74
+ npm run dev
75
+ ```
76
+
77
+ - npm needs the `--` before `--from`; with `pnpm`, `yarn` and `bun` it is not needed.
78
+ - A starter with `runtime: acrylblends` is an `acrylblends` app: this command downloads its folder, gives the manifest to the new app (`metadata.id: acrylblends.my-notes`, `visibility: private`; the starter's id and licence stay the starter's), checks it for anything that looks like a secret, and writes it. Nothing in it is installed or run: `npm install` is yours to run, and the dependencies of the starter (a desktop starter brings `electron`) are in its `package.json`.
79
+ - A starter for the ACRYL app (`runtime: acryl`, the older `acryl.*` entries) is refused with one line that says so, because its plugins run only inside the ACRYL app. Nothing is written.
80
+ - An unknown id lists the starters this command can make.
81
+ - The ids come from `https://acrylblends.github.io/registry/index.json`. `--registry github:owner/repo` reads another registry with the same layout (`index.json` and `blends/<id>/`), a team's own or a workshop's; `--registry ./folder` reads one on disk.
82
+
83
+ To publish a starter of your own, see [the registry README](https://github.com/acrylblends/acrylblends.github.io/tree/main/registry): the folder of your app under `blends/<id>/` with an `acryl.template.json`, in a pull request.
84
+
64
85
  ## Templates from a repository or a package
65
86
 
66
87
  A second argument after the directory makes the app from someone else's template:
@@ -11,6 +11,7 @@
11
11
  | **The agent panel** (`agent-panel`) | **experimental**: a script tag on every HTML page and `/api/agent-panel/*` | does nothing (no tag, no routes). `ACRYL_DOOR=off` too |
12
12
  | **The PTY driver** (`driver-pty`) | **experimental**: terminal drivers in the panel, `/api/driver-pty/*` | does nothing. `ACRYL_DOOR=off` too |
13
13
  | **Live plugin install and the agent** (`extensions`, `builder`, `agent`: `POST /ask`) | **experimental**: install, update, remove and reload plugins while the app runs; the agent builds them | left out: the `nodejs`, `notes` and `chat` templates drop the `extensions` row, and the `agent` template drops `agent`, `builder` and `extensions` (since 1.0.1) unless `ACRYL_PRODUCTION_KEEP` names them |
14
+ | **The chat template's model endpoint** (`POST /api/chat`) | answers anyone | **closed** (503) unless `CHAT_ACCESS_TOKEN` is set, or `ACRYL_PRODUCTION_KEEP=chat` makes it public with a warning and a per-address limit (since 1.0.2) |
14
15
  | `acrylblends/bun` | **experimental**: one smoke gate runs it on Bun | the same |
15
16
 
16
17
  Experimental means: it works and is gated, its interface may still change in a minor version, and it is not for the public internet. The door and the panel are loopback and own-page only, with a per-run secret and an audit log; that is a convenience fence for a developer's machine, not an authentication system.
@@ -22,6 +23,15 @@ The `agent` template mounts an agent behind `POST /ask` that spends the model ke
22
23
  - To keep a row anyway, name it: `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` (a comma list). Each row kept prints a `WARNING` line at start saying what it gives a visitor. Put your own login in front before you do.
23
24
  - **An app made by 1.0.0 keeps its own `src/app.ts` and `src/routes.ts`**, which `acrylblends upgrade` never touches (they are your files). Until you change them: deploy with the `agent`, `builder` and `extensions` rows set to `disabled: true` in `blend.yaml`, or start from another template, or copy the new `src/app.ts` and `src/routes.ts` of a fresh `acrylblends init my-app --template agent` over yours.
24
25
 
26
+ ## The `chat` template in production
27
+
28
+ The chat template answers with the owner's model key, so anyone who can reach it spends that key. Since 1.0.2, with `NODE_ENV=production` it is **closed**: every route but `GET /health` answers 503 until you choose one of:
29
+
30
+ - `CHAT_ACCESS_TOKEN=<a long random string>`: the browser asks for a login and takes any user name with the token as the password. Share the token with the people who may use the chat.
31
+ - `ACRYL_PRODUCTION_KEEP=chat`: the chat is public, with a `WARNING` line at start and a limit of `CHAT_RATE_PER_MINUTE` messages a minute from one address (default 20; behind a proxy you run, set `TRUST_PROXY=1` so the limit counts the visitor, not the proxy). Prefer your own login in front.
32
+
33
+ An app made by 1.0.0 or 1.0.1 keeps its own `src/routes.ts`: copy `src/access.ts`, `src/chat-app.ts` and `src/routes.ts` from a fresh `acrylblends init my-app --template chat`, or put a login in front.
34
+
25
35
  ## What live plugin install does and does not promise
26
36
 
27
37
  Installing a plugin (`ctx.extensions.install`, the door's `plugin add`, the builder's `install_plugin`) is a **correctness check, not a security boundary**:
@@ -41,6 +51,6 @@ Installing a plugin (`ctx.extensions.install`, the door's `plugin add`, the buil
41
51
  ## Checklist for a deployment
42
52
 
43
53
  1. `NODE_ENV=production` (the process manager or the Dockerfile sets it; the templates' Dockerfile does).
44
- 2. With the `agent` template, leave `ACRYL_PRODUCTION_KEEP` unset (1.0.1 and later), or remove the agent rows.
54
+ 2. With the `agent` template, leave `ACRYL_PRODUCTION_KEEP` unset (1.0.1 and later), or remove the agent rows. With the `chat` template, set `CHAT_ACCESS_TOKEN` (1.0.2 and later) or put your own login in front.
45
55
  3. Do not set `allowInProduction` on the door unless the host is a single-user desktop app.
46
56
  4. Keep `ACRYL_DOOR=off` in any environment where an agent should not be able to see the page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.1",
3
+ "version": "1.0.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",
@@ -1,5 +1,8 @@
1
1
  /** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
2
2
  import { spawn, spawnSync } from 'node:child_process'
3
+ import { readFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
3
6
  import type { Context } from '@deepseek-ai/cordis'
4
7
  import type { ChatDriver, ChatEvent } from './drivers.ts'
5
8
 
@@ -62,11 +65,30 @@ export function onPath(program: string): boolean {
62
65
  return found.status === 0
63
66
  }
64
67
 
65
- /** The headless programs this machine can offer: installed (pi also needs its key in the environment). */
68
+ /** Whether pi can reach DeepSeek: the key in the environment, or the one pi stored for itself when its owner logged in (`~/.pi/agent/auth.json`, a `deepseek` entry). */
69
+ export function piHasDeepseekKey(
70
+ env: NodeJS.ProcessEnv = process.env,
71
+ home: string = homedir(),
72
+ ): boolean {
73
+ if (env.DEEPSEEK_API_KEY) return true
74
+ try {
75
+ const stored = JSON.parse(
76
+ readFileSync(
77
+ join(env.PI_CODING_AGENT_DIR ?? join(home, '.pi', 'agent'), 'auth.json'),
78
+ 'utf8',
79
+ ),
80
+ ) as Record<string, { key?: unknown } | undefined>
81
+ return typeof stored.deepseek?.key === 'string' && stored.deepseek.key !== ''
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ /** The headless programs this machine can offer: installed (an executable on the PATH; a wrapper function around it is fine) and, for pi, able to reach its model. */
66
88
  export function availableHeadless(wanted?: readonly HeadlessName[]): HeadlessName[] {
67
89
  const found: HeadlessName[] = []
68
90
  for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
69
- if (driver === 'pi' && !process.env.DEEPSEEK_API_KEY) continue
91
+ if (driver === 'pi' && !piHasDeepseekKey()) continue
70
92
  if (onPath(driver)) found.push(driver)
71
93
  }
72
94
  return found
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
3
+ * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
+ * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { readFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
+ export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
14
+
15
+ /** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
16
+ export function sandboxBlocked(
17
+ platform: string,
18
+ read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
19
+ ): boolean {
20
+ if (platform !== 'linux') return false
21
+ try {
22
+ return read(RESTRICT_USERNS).trim() === '1'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export const SANDBOX_NOTICE =
29
+ "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
+ 'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
+
32
+ /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
+ export function electronArgs(entry: string, blocked: boolean): string[] {
34
+ return blocked ? ['--no-sandbox', entry] : [entry]
35
+ }
36
+
37
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
38
+ const electron = createRequire(import.meta.url)('electron') as string
39
+ // ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
40
+ const blocked =
41
+ process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
+ if (blocked) console.warn(SANDBOX_NOTICE)
43
+ const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
+ stdio: 'inherit',
45
+ })
46
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
48
+ }
@@ -9,7 +9,7 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron plugins/desktop/main.ts"
12
+ "desktop": "node plugins/desktop/launch.ts"
13
13
  },
14
14
  "omit": ["**/*.test.*"],
15
15
  "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop)"
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
3
+ * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
+ * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { readFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
+ export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
14
+
15
+ /** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
16
+ export function sandboxBlocked(
17
+ platform: string,
18
+ read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
19
+ ): boolean {
20
+ if (platform !== 'linux') return false
21
+ try {
22
+ return read(RESTRICT_USERNS).trim() === '1'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export const SANDBOX_NOTICE =
29
+ "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
+ 'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
+
32
+ /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
+ export function electronArgs(entry: string, blocked: boolean): string[] {
34
+ return blocked ? ['--no-sandbox', entry] : [entry]
35
+ }
36
+
37
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
38
+ const electron = createRequire(import.meta.url)('electron') as string
39
+ // ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
40
+ const blocked =
41
+ process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
+ if (blocked) console.warn(SANDBOX_NOTICE)
43
+ const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
+ stdio: 'inherit',
45
+ })
46
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
48
+ }
@@ -9,7 +9,7 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron plugins/desktop-web/main.ts"
12
+ "desktop": "node plugins/desktop-web/launch.ts"
13
13
  },
14
14
  "gitignore": [".acrylblends/"],
15
15
  "omit": ["**/*.test.*"],
@@ -13,4 +13,4 @@ A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `
13
13
 
14
14
  ## Before you ship
15
15
 
16
- Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key: put it behind a login or on a private network before it is public.
16
+ Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key, so with `NODE_ENV=production` the chat is **closed**: every route but `GET /health` answers 503 (`src/access.ts`). Set `CHAT_ACCESS_TOKEN` (the browser asks for it; any user name, the token as the password), or name the chat in `ACRYL_PRODUCTION_KEEP=chat` to make it public on purpose: that prints a `WARNING` at start and limits one address to `CHAT_RATE_PER_MINUTE` messages a minute (default 20). Prefer your own login in front of a public chat.
@@ -0,0 +1,110 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto'
2
+ import { connectionOf, createMiddleware } from '../plugins/acryl-router/src/helpers/index.ts'
3
+ import type { Context } from '../plugins/acryl-router/src/index.ts'
4
+ import { basicAuth } from '../plugins/acryl-router/src/middleware/index.ts'
5
+
6
+ /** Who may use the chat, and what the app prints about it at start. */
7
+ export type Access =
8
+ /** Development, and every test: no gate. */
9
+ | { kind: 'open' }
10
+ /** Production with `ACRYL_PRODUCTION_KEEP=chat`: anyone, with a per-IP rate limit. */
11
+ | { kind: 'public'; perMinute: number; trustProxy: boolean }
12
+ /** Production with `CHAT_ACCESS_TOKEN`: the browser asks for it (any user name, the token as the password). */
13
+ | { kind: 'token'; token: string }
14
+ /** Production with neither: every route but `/health` answers 503. */
15
+ | { kind: 'closed' }
16
+
17
+ export interface Decision {
18
+ readonly access: Access
19
+ /** Lines to print at start (a `WARNING` for a public chat, the reason for a closed one). */
20
+ readonly notices: readonly string[]
21
+ }
22
+
23
+ /**
24
+ * Anyone who can reach the chat spends the model key, so in production it is closed by default. Name the chat in `ACRYL_PRODUCTION_KEEP` to make it public
25
+ * (loudly, with a rate limit), or set `CHAT_ACCESS_TOKEN` to ask for a shared token.
26
+ */
27
+ export function chatAccess(env: Readonly<Record<string, string | undefined>>): Decision {
28
+ if (env.NODE_ENV !== 'production') return { access: { kind: 'open' }, notices: [] }
29
+ const keep = (env.ACRYL_PRODUCTION_KEEP ?? '')
30
+ .split(',')
31
+ .map((name) => name.trim())
32
+ .filter(Boolean)
33
+ if (keep.includes('chat')) {
34
+ const perMinute = Number(env.CHAT_RATE_PER_MINUTE)
35
+ return {
36
+ access: {
37
+ kind: 'public',
38
+ perMinute: Number.isInteger(perMinute) && perMinute > 0 ? perMinute : 20,
39
+ trustProxy: env.TRUST_PROXY === '1',
40
+ },
41
+ notices: [
42
+ 'WARNING: the chat is public in production because ACRYL_PRODUCTION_KEEP names "chat": anyone who can reach it spends your model key (each address is limited to a few messages a minute). Put your own login in front of it, or set CHAT_ACCESS_TOKEN instead.',
43
+ ],
44
+ }
45
+ }
46
+ const token = env.CHAT_ACCESS_TOKEN?.trim()
47
+ if (token) return { access: { kind: 'token', token }, notices: [] }
48
+ return {
49
+ access: { kind: 'closed' },
50
+ notices: [
51
+ 'The chat is closed: with NODE_ENV=production it needs CHAT_ACCESS_TOKEN (the browser asks for it) or ACRYL_PRODUCTION_KEEP=chat to be public. Every route but /health answers 503 until then.',
52
+ ],
53
+ }
54
+ }
55
+
56
+ const digest = (text: string) => createHash('sha256').update(text).digest()
57
+
58
+ /** The middleware for an access mode, mounted before the routes. `/health` is always open. */
59
+ export function accessGate(access: Access) {
60
+ if (access.kind === 'open') return undefined
61
+ if (access.kind === 'closed')
62
+ return createMiddleware(async (c, next) => {
63
+ if (c.req.path === '/health') return next()
64
+ return c.json(
65
+ {
66
+ error:
67
+ 'The chat is closed. Set CHAT_ACCESS_TOKEN, or ACRYL_PRODUCTION_KEEP=chat to make it public.',
68
+ },
69
+ 503,
70
+ )
71
+ })
72
+ if (access.kind === 'token') {
73
+ const expected = digest(access.token)
74
+ const ask = basicAuth({
75
+ realm: 'chat',
76
+ verifyUser: (_user, password) => timingSafeEqual(digest(password), expected),
77
+ })
78
+ return createMiddleware(async (c, next) => (c.req.path === '/health' ? next() : ask(c, next)))
79
+ }
80
+ return rateLimit(access.perMinute, access.trustProxy)
81
+ }
82
+
83
+ /** At most `perMinute` messages a minute from one address (`POST /api/chat`); the table forgets an address a minute after its last message. */
84
+ function rateLimit(perMinute: number, trustProxy: boolean) {
85
+ const seen = new Map<string, number[]>()
86
+ const addressOf = (c: Context) => {
87
+ if (trustProxy) {
88
+ const forwarded = c.req.header('x-forwarded-for')?.split(',').at(-1)?.trim()
89
+ if (forwarded) return forwarded
90
+ }
91
+ return connectionOf(c).remoteAddress ?? 'unknown'
92
+ }
93
+ return createMiddleware(async (c, next) => {
94
+ if (c.req.method !== 'POST' || c.req.path !== '/api/chat') return next()
95
+ const now = Date.now()
96
+ for (const [address, times] of seen)
97
+ if (now - (times.at(-1) ?? 0) > 60_000) seen.delete(address)
98
+ const address = addressOf(c)
99
+ const recent = (seen.get(address) ?? []).filter((time) => now - time < 60_000)
100
+ if (recent.length >= perMinute) {
101
+ seen.set(address, recent)
102
+ const wait = Math.ceil((60_000 - (now - (recent[0] ?? now))) / 1000)
103
+ c.header('retry-after', String(wait))
104
+ return c.json({ error: 'Too many messages. Try again in a minute.' }, 429)
105
+ }
106
+ recent.push(now)
107
+ seen.set(address, recent)
108
+ return next()
109
+ })
110
+ }
@@ -5,6 +5,7 @@ import {
5
5
  streamSSE,
6
6
  } from '../plugins/acryl-router/src/helpers/index.ts'
7
7
  import { Acryl } from '../plugins/acryl-router/src/index.ts'
8
+ import { type Access, accessGate } from './access.ts'
8
9
  import { chatStore } from './db.ts'
9
10
  import { type Llm, openAiCompatible } from './llm.ts'
10
11
  import { chatPage } from './page.tsx'
@@ -32,9 +33,14 @@ const ChatSchema: StandardSchema<ChatInput> = {
32
33
  },
33
34
  }
34
35
 
35
- export function createApp(db: Db, llm: Llm = openAiCompatible) {
36
+ /** `access` is who may use the chat (default: anyone, as in development and in the tests); see `src/access.ts`. */
37
+ export function createApp(db: Db, llm: Llm = openAiCompatible, access: Access = { kind: 'open' }) {
36
38
  const store = chatStore(db)
37
39
  const app = new Acryl()
40
+ const gate = accessGate(access)
41
+ if (gate) app.use(gate)
42
+
43
+ app.get('/health', (c) => c.json({ ok: true }))
38
44
 
39
45
  app.get('/', async (c) => {
40
46
  const id = Number(c.req.query('c'))
@@ -1,5 +1,6 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import type {} from '../plugins/acryl-db/src/cordis.ts'
3
+ import { chatAccess } from './access.ts'
3
4
  import { createApp } from './chat-app.ts'
4
5
  import { openAiCompatible } from './llm.ts'
5
6
  import { createClient } from './runtime.ts'
@@ -9,7 +10,10 @@ export const routes = {
9
10
  name: 'routes',
10
11
  inject: ['router', 'db'],
11
12
  apply(ctx: Context) {
12
- ctx.router.route('/', createApp(ctx.db, openAiCompatible))
13
+ // In production the chat is closed unless it has a token or is made public on purpose: anyone who reaches it spends the model key.
14
+ const { access, notices } = chatAccess(process.env)
15
+ for (const line of notices) console.warn(line)
16
+ ctx.router.route('/', createApp(ctx.db, openAiCompatible, access))
13
17
  ctx.router.route('/client', createClient())
14
18
  },
15
19
  }
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
7
7
  import type {} from '../plugins/acryl-db/src/cordis.ts'
8
8
  import type { Db } from '../plugins/acryl-db/src/index.ts'
9
9
  import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
10
+ import { chatAccess } from '../src/access.ts'
10
11
  import { createApp as boot } from '../src/app.ts'
11
12
  import { createApp } from '../src/chat-app.ts'
12
13
  import type { Llm } from '../src/llm.ts'
@@ -196,3 +197,67 @@ test('the chat page loads its client component, served by the running app', asyn
196
197
  assert.match(module.headers.get('content-type') ?? '', /javascript/)
197
198
  assert.equal((await running.root.router.request('/client/@acrylblends/jsx-dom.js')).status, 200)
198
199
  })
200
+
201
+ const production = (env: Record<string, string> = {}) =>
202
+ chatAccess({ NODE_ENV: 'production', ...env })
203
+
204
+ test('in development the chat is open and prints nothing', () => {
205
+ const { access, notices } = chatAccess({})
206
+ assert.equal(access.kind, 'open')
207
+ assert.deepEqual(notices, [])
208
+ })
209
+
210
+ test('in production the chat is closed until it has a token or is made public on purpose', async () => {
211
+ const closed = production()
212
+ assert.equal(closed.access.kind, 'closed')
213
+ assert.match(closed.notices.join(), /closed/)
214
+ const { llm, asked } = mock(['x'])
215
+ const app = createApp(await memoryDb(), llm, closed.access)
216
+ for (const path of ['/', '/api/conversations'])
217
+ assert.equal((await app.request(path)).status, 503, path)
218
+ assert.equal((await app.request('/api/chat', post({ message: 'hi' }))).status, 503)
219
+ assert.equal((await app.request('/health')).status, 200)
220
+ assert.equal(asked.length, 0)
221
+ })
222
+
223
+ test('with CHAT_ACCESS_TOKEN the browser must send it, as the password of a basic login', async () => {
224
+ const gated = production({ CHAT_ACCESS_TOKEN: 'a-long-shared-token' })
225
+ assert.equal(gated.access.kind, 'token')
226
+ assert.deepEqual(gated.notices, [])
227
+ const { llm, asked } = mock(['ok'])
228
+ const app = createApp(await memoryDb(), llm, gated.access)
229
+ const login = (password: string) => ({
230
+ ...post({ message: 'hi' }),
231
+ headers: {
232
+ 'content-type': 'application/json',
233
+ authorization: `Basic ${Buffer.from(`me:${password}`).toString('base64')}`,
234
+ },
235
+ })
236
+ assert.equal((await app.request('/api/chat', post({ message: 'hi' }))).status, 401)
237
+ assert.equal((await app.request('/api/chat', login('wrong'))).status, 401)
238
+ assert.equal((await app.request('/health')).status, 200)
239
+ assert.equal(asked.length, 0)
240
+ const ok = await app.request('/api/chat', login('a-long-shared-token'))
241
+ assert.equal(ok.status, 200)
242
+ assert.deepEqual(
243
+ events(await ok.text()).map(([e]) => e),
244
+ ['meta', 'token', 'done'],
245
+ )
246
+ })
247
+
248
+ test('ACRYL_PRODUCTION_KEEP=chat makes the chat public with a loud WARNING and a per-address rate limit', async () => {
249
+ const open = production({ ACRYL_PRODUCTION_KEEP: 'agent, chat', CHAT_RATE_PER_MINUTE: '2' })
250
+ assert.equal(open.access.kind, 'public')
251
+ assert.match(open.notices.join(), /^WARNING: the chat is public in production/)
252
+ const { llm, asked } = mock(['ok'])
253
+ const app = createApp(await memoryDb(), llm, open.access)
254
+ const send = () => app.request('/api/chat', post({ message: 'hi' }))
255
+ assert.equal((await (await send()).text()).includes('event: done'), true)
256
+ assert.equal((await send()).status, 200)
257
+ const limited = await send()
258
+ assert.equal(limited.status, 429)
259
+ assert.ok(Number(limited.headers.get('retry-after')) > 0)
260
+ assert.equal(asked.length, 2)
261
+ // Other routes are not counted.
262
+ assert.equal((await app.request('/api/conversations')).status, 200)
263
+ })
@@ -62,7 +62,7 @@ test('the home page sends a visitor to the notes, which send them to log in', as
62
62
  test('sign up validates, refuses a duplicate, and starts a session', async () => {
63
63
  const app = createApp(await memoryDb())
64
64
  assert.match(await (await app.request('/signup')).text(), /Create account/)
65
- const bad = await app.request('/signup', form({ email: 'nope', password: 'short' }))
65
+ const bad = await app.request('/signup', form({ email: 'nope', password: 'no way' }))
66
66
  assert.equal(bad.status, 400)
67
67
  const text = await bad.text()
68
68
  assert.match(text, /valid email/)
@@ -9,6 +9,8 @@ const chosen = selectInstance({
9
9
  })
10
10
  const app = await createApp(chosen)
11
11
  console.log(`${APP_NAME} listening on ${app.url} (data in ${chosen.home})`)
12
+ // The list is served under /todo, not at the address above: say where to open it.
13
+ console.log(`open ${app.url}/todo`)
12
14
 
13
15
  const stop = async () => {
14
16
  await app.stop()