open-design/nix/README.md
Marc Chan 10192dcc52
fix(ci): catch nix hash drift before merge (#2530)
* fix(ci): catch nix hash drift before merge

* fix(nix): add pnpm hash refresh helper

* chore(nix): drop redundant hash alias

* fix(nix): raise update-hash output buffer

Generated-By: looper 0.8.1 (runner=fixer, agent=opencode)

* fix(nix): handle current pnpm deps hash

Generated-By: looper 0.8.1 (runner=fixer, agent=opencode)

* fix(nix): reject non-mismatch hash updates

Generated-By: looper 0.8.1 (runner=fixer, agent=opencode)
2026-05-21 16:08:13 +08:00

246 lines
12 KiB
Markdown

# Open Design — Nix flake
This flake exposes Open Design as a reproducible package, a `nix run` entry
point, a dev shell, and Home Manager / NixOS modules. The architecture
mirrors the runtime: the **daemon** (`od` CLI, Express API on `/api/*`)
and the **web frontend** (Next.js static SPA at `apps/web/out/`) are
**separate packages** and **separate services** — you can run either or
both.
## Outputs
| Output | What it is |
| ------------------------------------------ | -------------------------------------------------------------------------------------- |
| `packages.<system>.daemon` | The `@open-design/daemon` package — produces `bin/od`. Default output. |
| `packages.<system>.web` | The Next.js static export (`apps/web/out/`) ready to drop into any static file server. |
| `apps.<system>.default` | `nix run github:nexu-io/open-design` — boots the daemon. |
| `devShells.<system>.default` | Node 24 + Corepack-pinned pnpm 10.33 — reproduces `pnpm install` locally. |
| `homeManagerModules.{default,open-design}` | Home Manager module — primary individual-developer interface. |
| `nixosModules.{default,open-design}` | NixOS module — secondary, for shared/server installs. |
## Try it without installing
```bash
nix run github:nexu-io/open-design # boots the daemon on :7457
nix develop github:nexu-io/open-design # drop into the dev shell
```
## (1) Home Manager — the recommended path
For an individual workstation, add the flake as an input and import the
default module:
```nix
{
inputs.open-design.url = "github:nexu-io/open-design";
outputs = { self, home-manager, open-design, ... }: {
homeConfigurations.you = home-manager.lib.homeManagerConfiguration {
modules = [
open-design.homeManagerModules.default
{
services.open-design = {
enable = true;
autoStart = true; # systemd --user / launchd agent
webFrontend.enable = true; # also run the static SPA on :5174
};
}
];
};
};
}
```
What this wires up:
- Linux: `systemd --user` units `open-design.service` and (optionally)
`open-design-web.service`. `systemctl --user status open-design`.
- macOS: `launchd` agents `io.nexu.open-design` and (optionally)
`io.nexu.open-design-web`. `launchctl print gui/$UID/io.nexu.open-design`.
- Data lives in `$HOME/.od/` by default — override `dataDir` to relocate.
## (2) NixOS — for shared/server installs
```nix
{
imports = [ inputs.open-design.nixosModules.default ];
services.open-design = {
enable = true;
autoStart = true;
openFirewall = true;
webFrontend.enable = true;
user = "open-design";
group = "open-design";
};
}
```
This creates a system user, drops a tmpfiles rule for `/var/lib/open-design`,
and runs the daemon under hardened systemd (`ProtectSystem=strict`,
`PrivateTmp`, `ReadWritePaths` scoped to the data directory). Use this
when you want a single shared instance — for individual user
configuration prefer the Home Manager module.
## (3) `webFrontend` — when to use it, when to bring your own server
Open Design's frontend is a static SPA that issues relative `/api/*`,
`/artifacts/*`, and `/frames/*` requests. Three serving options:
| Option | When |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `webFrontend.enable = true` | You want one-line setup. The module spawns a tiny Caddy file server on `webFrontend.port` (default `5174`) that serves the SPA and reverse-proxies the three path prefixes to the daemon. |
| `webFrontend.enable = false` (default) | You're running nginx / Caddy / Apache / Traefik yourself. Point your server's document root at `${pkgs.open-design.web}` (or the `packages.<system>.web` output) and replicate the proxy contract in section (4). |
| Skip the frontend entirely | You only need the daemon's API for headless agent dispatch. |
The two services are independent. `autoStart` controls the daemon;
`webFrontend.enable` controls the static server. Mix freely.
> **Bring-your-own-server gotcha:** if your proxy listens on any
> origin that differs from the daemon's bind (different host _or_
> different port — even loopback split-port like
> `http://127.0.0.1:8080` while the daemon stays on `:7457`), the
> daemon's same-origin gate will 403 the SPA's writes until you tell
> it about that origin. Either set
> `services.open-design.webFrontend.allowedOrigins = [ "<your-proxy-origin>" ]`
> (which feeds `OD_ALLOWED_ORIGINS`) or, for the loopback-only
> split-port case, set `extraEnv.OD_WEB_PORT = "<proxy-port>"`. See
> section (4) for the full decision tree.
### Exposing the bundled frontend on a non-loopback host
By default `webFrontend.host = "127.0.0.1"` so enabling the bundled
caddy does not publish anything beyond loopback. To intentionally
share with a LAN, two settings must be widened together — the
modules assert at eval time that the second is set whenever the
first is widened:
```nix
services.open-design.webFrontend = {
enable = true;
host = "0.0.0.0"; # caddy listener
# Every external origin browsers will load the SPA from. The daemon
# matches each entry against the browser's `Origin` header AND adds
# its host:port to the `Host`-header allowlist (Caddy v2 reverse_proxy
# preserves the original Host upstream by default), so list each
# scheme + hostname combo you actually use.
allowedOrigins = [
"http://laptop.local:5174"
"https://laptop.local:5174"
];
};
# On NixOS you also need:
services.open-design.openFirewall = true;
```
Under the hood `allowedOrigins` is forwarded to the daemon as the
`OD_ALLOWED_ORIGINS` environment variable (comma-separated). If you
run the daemon outside the modules — for example, behind your own
nginx/caddy — set `OD_ALLOWED_ORIGINS` directly in the daemon's
environment with the same shape:
```
OD_ALLOWED_ORIGINS=http://host1:port,https://host1:port,http://host2:port
```
Each entry must be a bare origin (`scheme://host[:port]`); only
`http://` and `https://` schemes are accepted, and the daemon refuses
to start if any entry fails to parse. The variable widens only the
general `/api/*` same-origin gate — connector-credential and
live-artifact preview/refresh routes stay strictly loopback-only by
design.
## (4) Same-origin proxying contract
The web package is built with `OD_DAEMON_URL = ""` so the bundled JS
issues **relative** requests — `/api/*`, `/artifacts/*`, `/frames/*`
instead of baking a daemon URL into the export. There is no runtime
config endpoint; the SPA does not read `OD_DAEMON_URL` from the
serving environment.
The serving contract is therefore: **the static export must be served
same-origin with a reverse proxy to the daemon**. The bundled caddy
service does exactly this — `webFrontend` listens on
`webFrontend.port` and reverse-proxies the three path prefixes above
to `127.0.0.1:<cfg.port>`, with `flush_interval -1` and no `encode` on
`/api/*` so SSE streams flush immediately (gzip would buffer chunked
responses for ~80s and surface as `ERR_INCOMPLETE_CHUNKED_ENCODING`).
If you serve the static bundle yourself, replicate that shape:
- Document root → `${pkgs.open-design.web}` (or
`packages.<system>.web`).
- Reverse-proxy `/api/*`, `/artifacts/*`, `/frames/*` to the daemon's
bind address; `/api/*` must stream chunks immediately and skip
response compression.
- SPA fallback for unmatched paths → `index.html`.
The static-server's environment does not need any Open Design env
vars — but **the daemon's environment usually does**, because its
same-origin gate is built from `OD_BIND_HOST:port` (loopback hosts
included). The browser's `Origin` and `Host` are whatever your proxy
exposes, so unless that matches `127.0.0.1:<daemon-port>` exactly,
the daemon will 403 every PUT/POST until told otherwise:
| Your custom-server setup | What to set on the daemon |
| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proxy at `http://127.0.0.1:<daemon-port>` (same host, same port — unusual) | Nothing. |
| Proxy at a loopback host but different port (e.g. `http://127.0.0.1:8080` while daemon is on `:7457`) | Either `extraEnv.OD_WEB_PORT = "8080"` (whitelists `8080` on every loopback host) or `services.open-design.webFrontend.allowedOrigins`. |
| Proxy on any non-loopback host (LAN IP, mDNS name, Tailscale name, public domain — `https://od.example.com`, `http://laptop.local:5174`, …) | `services.open-design.webFrontend.allowedOrigins = [ "<full origin>" ]`. List every scheme + host[:port] combo a browser might load the SPA from. |
`webFrontend.allowedOrigins` is forwarded to the daemon as
`OD_ALLOWED_ORIGINS`; if you run the daemon outside the modules,
export `OD_ALLOWED_ORIGINS` directly with the same shape (see
section (3)). The variable widens only the general `/api/*` gate —
connector-credential and live-artifact preview/refresh routes stay
strictly loopback-only by design.
## (5) Secrets — DO NOT put them in your Nix config
The `environmentFile` option takes a path to a `KEY=VALUE` file that the
service unit reads. Use it for BYOK API keys (Anthropic, OpenAI, Gemini),
provider tokens, and anything else you do not want world-readable in
`/nix/store`.
Recommended secret managers:
- [sops-nix](https://github.com/Mic92/sops-nix) — age- or PGP-encrypted
YAML, decrypted into runtime files at activation.
- [agenix](https://github.com/ryantm/agenix) — age-encrypted single
files, dropped into `/run/agenix/` at boot.
Either renders to a file like `/run/secrets/open-design.env`; pass that
path:
```nix
services.open-design.environmentFile = "/run/secrets/open-design.env";
```
Never inline a secret with `pkgs.writeText` or `home.file`.
## First-build hash pinning
`nix/pnpm-deps.nix` is the single source of truth for the vendored pnpm
store hash used by both `nix/package-daemon.nix` and
`nix/package-web.nix`. If `pnpm-lock.yaml` changes, run:
```bash
pnpm nix:update-hash
```
The script temporarily swaps one consumer to `lib.fakeHash`, runs
`nix build .#web --print-build-logs`, extracts the expected hash from the
failure output, writes it back into `nix/pnpm-deps.nix`, and restores the
consumer file.
## CI
`.github/workflows/nix-check.yml` runs `nix flake check` on pushes to
`main` and can also be started manually with `workflow_dispatch`.
Pull requests that touch Nix or dependency inputs are validated earlier in
`.github/workflows/ci.yml` via the required `Validate workspace` gate.
That PR path runs `nix flake check` when `pnpm-lock.yaml`, package
manifests, `flake.*`, `nix/**`, or the Nix workflows change, so fixed-
output hash drift is caught before merge while keeping unrelated PRs off
the slower Nix path.