spatial-office/spatial-office.nix
Engineering (agent-engineering) bb06f2c48c build: add Nix flake and NixOS module for reproducible builds
- flake.nix: devShell, frontend package, backend package, combined default
- spatial-office.nix: standalone NixOS module for importing into n97 config
- packages/frontend: React + @react-three/fiber + Zustand (Vite build)
- packages/backend: Node.js + ws + Zod (WebSocket server, port 18790)
- pnpm-workspace.yaml: monorepo workspace config

nix build .#frontend  — bundled static site
nix build .#backend   — runnable WebSocket server
nix build             — full spatial-office application
nix develop           — dev shell with Node.js 22 + pnpm
2026-05-19 16:25:24 -07:00

184 lines
6.2 KiB
Nix

# spatial-office.nix — NixOS module for the Spatial Office service
#
# Import into your NixOS configuration:
#
# imports = [ ./spatial-office.nix ];
# services.spatial-office.enable = true;
# services.spatial-office.port = 18790;
# services.spatial-office.openclawTokenFile = "/run/secrets/openclaw-token";
#
# This module provides:
# - A systemd service running the spatial-office backend
# - A dedicated spatial-office system user
# - Firewall port opening for the configured port
#
# Requirements:
# - The spatial-office flake must be available as an input in your flake.nix
# - Or the spatial-office package must be in your nixpkgs overlay
#
# Usage with flake-parts or raw NixOS config:
#
# { config, inputs, pkgs, ... }: {
# imports = [ inputs.spatial-office.nixosModules.spatial-office-service ];
# services.spatial-office = {
# enable = true;
# port = 18790;
# openclawTokenFile = config.sops.secrets.openclaw-token.path;
# };
# }
{ config, lib, pkgs, ... }:
let
cfg = config.services.spatial-office;
in
{
options.services.spatial-office = {
enable = lib.mkEnableOption "Spatial Office service — isometric agent workspace";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.spatial-office-backend or (throw ''
spatial-office package not found in nixpkgs.
Add the spatial-office flake as an input and set:
services.spatial-office.package = inputs.spatial-office.packages.''${pkgs.system}.backend;
'');
description = "The spatial-office backend package to use";
};
frontendPackage = lib.mkOption {
type = lib.types.package;
default = pkgs.spatial-office-frontend or (throw ''
spatial-office frontend package not found in nixpkgs.
Add the spatial-office flake as an input and set:
services.spatial-office.frontendPackage = inputs.spatial-office.packages.''${pkgs.system}.frontend;
'');
description = "The spatial-office frontend package (static files)";
};
port = lib.mkOption {
type = lib.types.port;
default = 18790;
description = "WebSocket server port for the spatial-office backend";
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Host address to bind the backend to";
};
openclawTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to a file containing the OpenClaw gateway shared token.
The file must be readable by the spatial-office user.
Use with sops-nix or agenix for secret management.
'';
};
openclawGatewayUrl = lib.mkOption {
type = lib.types.str;
default = "ws://127.0.0.1:18789";
description = "WebSocket URL of the OpenClaw gateway";
};
frontendDir = lib.mkOption {
type = lib.types.path;
default = cfg.frontendPackage;
description = "Directory of built frontend static files to serve";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "debug" "info" "warn" "error" ];
default = "info";
description = "Log level for the spatial-office backend";
};
extraEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Extra environment variables to pass to the service";
};
};
config = lib.mkIf cfg.enable {
# ── Systemd service ────────────────────────────────────────────
systemd.services.spatial-office = {
description = "Spatial Office — isometric agent workspace backend";
documentation = [ "https://os.scoble.me/forgejo/sscoble/spatial-office" ];
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "network-online.target" ];
wants = [ "network-online.target" ];
# If a token file is configured, make sure it's available
unitConfig = lib.mkIf (cfg.openclawTokenFile != null) {
RequiresMountsFor = [ (builtins.dirOf cfg.openclawTokenFile) ];
};
environment = {
PORT = toString cfg.port;
HOST = cfg.host;
SPATIAL_OFFICE_FRONTEND_DIR = "${cfg.frontendDir}";
LOG_LEVEL = cfg.logLevel;
NODE_ENV = "production";
OPENCLAW_GATEWAY_URL = cfg.openclawGatewayUrl;
} // (lib.optionalAttrs (cfg.openclawTokenFile != null) {
OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile;
}) // cfg.extraEnvironment;
serviceConfig = {
ExecStart = "${cfg.package}/bin/spatial-office-backend";
Restart = "always";
RestartSec = 5;
# Run as dedicated user
User = "spatial-office";
Group = "spatial-office";
# Security hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictRealtime = true;
# Node.js needs W^X disabled for JIT
MemoryDenyWriteExecute = false;
# Allow network and basic syscalls
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
# Restrict filesystem access
ReadWritePaths = [ ];
ReadOnlyPaths = lib.optionals (cfg.openclawTokenFile != null) [ cfg.openclawTokenFile ];
InaccessiblePaths = [ "/root" "/etc/ssh" ];
# Resource limits
LimitNOFILE = 4096;
};
};
# ── Service user ───────────────────────────────────────────────
users.users.spatial-office = {
isSystemUser = true;
group = "spatial-office";
description = "Spatial Office service user";
home = "/var/lib/spatial-office";
createHome = true;
};
users.groups.spatial-office = { };
# ── Firewall ───────────────────────────────────────────────────
networking.firewall.allowedTCPPorts = [ cfg.port ];
};
}