open-design/nix/module-common.nix
Chris Tam c61ba320fd
feat(nix): Add official flake with home-manager and NixOS support (#402)
* nix: add official flake with home-manager and nixos modules

* Pin pnpm version

* Format README.md

* Populate PATH files to discover installed CLIs

* Revert "Populate PATH files to discover installed CLIs"

This reverts commit 18d88781a88b8781913cf5a8b680dfb38eabf7e4.

* Fix missing sqlite issue

* Fix system issue

* Reapply "Populate PATH files to discover installed CLIs"

This reverts commit d02ea994e6.

* Handle different ports for web frontend

* Provide documentation for getting pnpm hash

* Enable nix flake checks for code changes

* Set `OD_WEB_PORT` on daemon when declared

* fix: Fix environmentFile for macOS targets

* chore: Ignore nix and direnv related files

* fix: Read version directly from `package.json`

* feat: Make nix shell entry prettier

* chore: Update pnpm hashes

* chore: Bump `pnpm` hashes

* docs: Add blurb about dev shell in `README.md`

* Address review comments

* Add support for `OD_WEB_ORIGINS`

* Fix `isLocalSameOrigin`

* Update pnpm checksums

* docs: Update documentation on host origins

* Move allowedOrigins mapping out of the webFrontend.enable guard

* fix: Bump pnpm hashes

* Remove changes to `daemon` with `main` changes

`main` merged a feature that addressed our need for allowed origins.
Since this feature branch no longer needs it, remove any remaining
changes in `daemon` code so that this is a pure Nix change.

* Update documentation around `OD_DAEMON_URL`

* Rewrite option docs to match same-origin proxy contract

The port, webFrontend, and webFrontend.port option descriptions still
described OD_DAEMON_URL as the runtime contract for the SPA, but the
SPA issues relative /api/*, /artifacts/*, /frames/* requests and there
is no runtime daemon-URL injection. Rewrite the three blocks to
describe what the caddy / custom proxy must actually do.

* Document daemon-side requirements for custom-server proxy paths

The bring-your-own-server path in section (3) and the same-origin
contract in section (4) understated what the daemon needs: any proxy
whose origin differs from the daemon's bind (including loopback
split-port like 127.0.0.1:8080 while the daemon stays on :7457) is
403'd by the daemon's same-origin gate until told about that origin.

Add a callout under section (3)'s table, expand section (4) with a
decision table covering same-port, loopback split-port (OD_WEB_PORT or
webFrontend.allowedOrigins), and non-loopback (webFrontend.allowedOrigins)
cases, and rewrite the webFrontend.allowedOrigins option description to
enumerate the cases where it's required and surface OD_WEB_PORT as an
alternative for the loopback split-port case.

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-09 23:50:16 +08:00

244 lines
10 KiB
Nix

# Shared option definitions for the Open Design Home Manager and NixOS
# modules. Returns a plain attrset of options (NOT a NixOS module). The
# consuming module imports this and merges the result into its own
# `options.<scope>.open-design`.
#
# The two callers differ only in:
# - default `dataDir` (HM: $HOME/.od; NixOS: /var/lib/open-design)
# - service supervision (HM: systemd --user / launchd agents;
# NixOS: system systemd units + dynamic user)
# Everything else — port, autoStart, environmentFile, agents, webFrontend —
# is identical across both, so it lives here.
{
lib,
pkgs,
flake,
defaultDataDir,
}: let
# `pkgs.system` is a legacy alias removed when consumers set
# `nixpkgs.config.allowAliases = false`. Use the canonical attribute
# path so the module evaluates under both default and strict configs.
systemAttr = pkgs.stdenv.hostPlatform.system;
flakePackages =
if flake ? packages.${pkgs.stdenv.hostPlatform.system}
then flake.packages.${pkgs.stdenv.hostPlatform.system}
else {};
in {
enable = lib.mkEnableOption "Open Design local-first design product daemon";
package = lib.mkOption {
type = lib.types.package;
default =
flakePackages.daemon or (throw
"open-design: no daemon package available for ${pkgs.stdenv.hostPlatform.system}; set services.open-design.package explicitly");
defaultText = lib.literalExpression "open-design.packages.\${pkgs.stdenv.hostPlatform.system}.daemon";
description = "The Open Design daemon package providing the `od` binary.";
};
port = lib.mkOption {
type = lib.types.port;
default = 7457;
description = ''
TCP port the daemon API binds to. Passed to `od --port`.
The static SPA issues relative `/api/*`, `/artifacts/*`, and
`/frames/*` requests, so whatever fronts the bundle (the
built-in `webFrontend` caddy or your own nginx/Caddy) must
reverse-proxy those three path prefixes to `127.0.0.1:<this
port>` and serve same-origin with the SPA. There is no runtime
`OD_DAEMON_URL` injection see section (4) of `nix/README.md`.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = defaultDataDir;
defaultText =
lib.literalExpression
(
if defaultDataDir == "/var/lib/open-design"
then "\"/var/lib/open-design\""
else "\"\${config.home.homeDirectory}/.od\""
);
description = ''
Directory holding the daemon's runtime state: SQLite database
(`app.sqlite`), per-project working trees under `projects/<id>/`,
and saved artifact bundles under `artifacts/`.
'';
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to register a service that starts the daemon automatically.
Independent of `webFrontend.enable` you can run either or both.
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to a file containing `KEY=VALUE` lines passed to the daemon's
service environment. Use this for runtime secrets (BYOK API keys,
provider tokens, etc.).
WARNING: never put secret values directly into Nix configuration
the Nix store is world-readable. Generate this file out-of-band
with sops-nix (https://github.com/Mic92/sops-nix) or agenix
(https://github.com/ryantm/agenix).
'';
example = "/run/secrets/open-design.env";
};
extraEnv = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = ''
Additional non-secret environment variables for the daemon
service (e.g. `OD_CODEX_DISABLE_PLUGINS = "1"`). Secrets belong
in `environmentFile`, not here.
'';
example = lib.literalExpression ''
{
OD_CODEX_DISABLE_PLUGINS = "1";
}
'';
};
extraBinPaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = ''
Extra absolute directories to prepend to the daemon service's
PATH. The daemon discovers agent CLIs (claude, codex, gemini,
opencode, ...) by scanning `process.env.PATH` at runtime, but
systemd unit and macOS launchd agent invocations launch with a
minimal default PATH that excludes Home Manager / NixOS user
profiles so without entries here the daemon will report
"no agents detected" even when the CLIs are installed.
Both modules pre-populate PATH with sensible defaults for their
context (Home Manager: the user's HM profile and, on NixOS, the
per-user/system profile dirs; NixOS: the system profile and
wrapper dirs). Use this option to add additional locations
(custom installs under `/opt`, project-local `bin/`, etc.).
'';
example = lib.literalExpression ''[ "/opt/agents/bin" ]'';
};
webFrontend = {
# The Open Design web frontend is a static SPA built by
# `apps/web` → `apps/web/out/`. The daemon is a separate Express
# process that serves the JSON API at `/api/*`. The SPA is built
# with `OD_DAEMON_URL=""`, so the bundled JS issues relative
# `/api/*`, `/artifacts/*`, and `/frames/*` requests and expects
# the static-server in front of it to reverse-proxy those to the
# daemon — there is no runtime daemon-URL injection.
#
# Enabling `webFrontend` runs a tiny static file server (caddy) that
# hosts the SPA on a sibling port and proxies the three path
# prefixes back to the daemon. This is for users who just want
# `nix run`-style convenience without configuring nginx/caddy by
# hand.
#
# If you already serve the static export through your own reverse
# proxy, leave `webFrontend.enable = false`, point your server's
# document root at `${cfg.webFrontend.package}`, and replicate the
# reverse-proxy rules (with SSE-safe streaming on `/api/*`).
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run a lightweight static file server for the Next.js export.
Independent of the daemon service: enable either or both.
'';
};
port = lib.mkOption {
type = lib.types.port;
# Confirmed via QUICKSTART.md examples (`--web-port 5175`) and
# tools-dev defaults — pick 5174 to leave 5175 free for the dev
# tools-dev workflow on the same machine.
default = 5174;
description = ''
TCP port the static file server binds to. The bundled caddy
serves the SPA on this port and reverse-proxies the SPA's
relative API requests (`/api/*`, `/artifacts/*`, `/frames/*`)
to the daemon at `127.0.0.1:''${toString cfg.port}`, so the
browser sees a single same-origin endpoint.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Interface address the static file server binds to. Defaults to
loopback so enabling `webFrontend` does not expose the bundled
proxy (which forwards `/api/*`, `/artifacts/*`, and `/frames/*`
to the local daemon) to other hosts on the network.
For shared deployments set this to `"0.0.0.0"` (or a specific
interface address) AND populate `webFrontend.allowedOrigins`
with every external origin the SPA will be loaded from the
daemon's same-origin gate is fail-closed and would otherwise
reject API writes proxied by caddy with a 403. On NixOS you
must additionally set `services.open-design.openFirewall =
true` for inbound traffic to reach the listener.
Note: certain sensitive routes (connector credentials, live
artifact preview/refresh) remain strictly loopback-only even
when `allowedOrigins` is set; that is intentional.
'';
};
allowedOrigins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = ''
Full HTTP(S) origins (`scheme://host[:port]`, no path) the
daemon should accept as same-site for `/api/*` requests in
addition to its built-in loopback set. Honored regardless of
`webFrontend.enable` set this whenever the browser-facing
origin differs from the daemon's bind address, including the
following cases:
* Bundled caddy on a non-loopback host: SPA loaded from
`http://<lan-host>:''${toString cfg.webFrontend.port}` cannot
issue PUT/POST through the bundled proxy without this set.
* Custom nginx/Caddy out front (`webFrontend.enable = false`)
on a non-loopback host or any port other than
`''${toString cfg.port}` including loopback split-port
setups like `http://127.0.0.1:8080`.
For the loopback split-port case specifically, an alternative is
to set `extraEnv.OD_WEB_PORT = "<proxy-port>"`, which whitelists
that port on every loopback host without enumerating origins.
Each entry is forwarded to the daemon via the `OD_ALLOWED_ORIGINS`
env var. The daemon both compares it verbatim against the
browser's `Origin` header AND admits its host:port to the
`Host`-header allowlist (Caddy v2 reverse_proxy preserves the
original Host upstream by default, so widening only the Origin
check would still 403 same-site GETs at the Host check). List
every hostname/scheme combo a user might access (LAN IP, mDNS
name, Tailscale name, http and https separately if both are
reachable). Loopback origins on the daemon's own port are
already accepted unconditionally do not bother listing those.
'';
example = ["http://laptop.local:5174" "https://laptop.local:5174"];
};
package = lib.mkOption {
type = lib.types.package;
default =
flakePackages.web or (throw
"open-design: no web package available for ${pkgs.stdenv.hostPlatform.system}; set services.open-design.webFrontend.package explicitly");
defaultText = lib.literalExpression "open-design.packages.\${pkgs.stdenv.hostPlatform.system}.web";
description = "Built static export to serve (Next.js out/ tree).";
};
};
}