# 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 ]; }; }