{ description = "Spatial Office — isometric collaborative workspace for agents and humans"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; # ── Node.js 22 toolchain ────────────────────────────────────── nodeToolchain = with pkgs; [ nodejs_22 corepack_22 ]; # ── Frontend package ────────────────────────────────────────── # React + @react-three/fiber + Zustand, built with Vite frontend = pkgs.buildNpmPackage { name = "spatial-office-frontend"; version = "0.1.0"; src = ./packages/frontend; npmDepsHash = "sha256-yZajnZGRNnZo+zm8b0N9+nNOoyxt2EOoxwWYINl6FZU="; nativeBuildInputs = with pkgs; [ nodejs_22 corepack_22 ]; buildPhase = '' runHook preBuild npm run build runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out cp -r dist/* $out/ runHook postInstall ''; meta = with pkgs.lib; { description = "Spatial Office frontend — React + Three.js isometric workspace"; license = licenses.mit; }; }; # ── Backend package ─────────────────────────────────────────── # Node.js + ws + Zod, WebSocket server on port 18790 backend = pkgs.buildNpmPackage { name = "spatial-office-backend"; version = "0.1.0"; src = ./packages/backend; npmDepsHash = "sha256-GB3oOOAD7RJwkUR21zA9R66b5MlLXMU0IwOOgflfE14="; nativeBuildInputs = with pkgs; [ nodejs_22 corepack_22 ]; buildPhase = '' runHook preBuild npm run build runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out/lib $out/bin # Copy built JS output cp -r dist/* $out/lib/ # Copy node_modules for runtime deps (ws, zod, etc.) cp -r node_modules $out/lib/ # Wrapper script cat > $out/bin/spatial-office-backend << 'WRAPPER' #!${pkgs.bash}/bin/bash exec ${pkgs.nodejs_22}/bin/node "$out/lib/index.js" "$@" WRAPPER chmod +x $out/bin/spatial-office-backend runHook postInstall ''; meta = with pkgs.lib; { description = "Spatial Office backend — Node.js WebSocket server with OpenClaw bridge"; license = licenses.mit; mainProgram = "spatial-office-backend"; }; }; # ── Combined package (symlink forest of frontend + backend) ──── spatial-office = pkgs.buildEnv { name = "spatial-office"; version = "0.1.0"; paths = [ frontend backend ]; meta = with pkgs.lib; { description = "Spatial Office — full application (frontend + backend)"; license = licenses.mit; }; }; in { packages = { inherit frontend backend; default = spatial-office; }; devShells = { default = pkgs.mkShell { name = "spatial-office-dev"; buildInputs = nodeToolchain ++ (with pkgs; [ ]); shellHook = '' echo "" echo "🏢 Spatial Office dev shell" echo " pnpm install — install workspace dependencies" echo " pnpm dev — start frontend + backend in dev mode" echo " pnpm build — production build" echo " nix build — full Nix build" echo "" ''; }; }; # ── NixOS module ─────────────────────────────────────────────── nixosModules = { spatial-office = { config, lib, pkgs, ... }: let cfg = config.services.spatial-office; in { options.services.spatial-office = { enable = lib.mkEnableOption "Spatial Office service"; port = lib.mkOption { type = lib.types.int; default = 18790; description = "WebSocket server port"; }; openclawTokenFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = "File containing the OpenClaw gateway shared token"; }; frontendDir = lib.mkOption { type = lib.types.path; default = "${self.packages.${system}.frontend}"; 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"; }; }; config = lib.mkIf cfg.enable { systemd.services.spatial-office = { description = "Spatial Office Backend"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; wants = [ "network.target" ]; requires = [ ]; environment = { PORT = toString cfg.port; SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir; LOG_LEVEL = cfg.logLevel; NODE_ENV = "production"; OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; } // (lib.optionalAttrs (cfg.openclawTokenFile != null) { OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile; }); serviceConfig = { ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend"; Restart = "always"; RestartSec = 5; User = "spatial-office"; Group = "spatial-office"; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; ReadWritePaths = [ ]; PrivateTmp = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; MemoryDenyWriteExecute = false; # Node.js needs W^X disabled RestrictRealtime = true; SystemCallFilter = [ "@system-service" "~@privileged" ]; }; }; # Service user users.users.spatial-office = { isSystemUser = true; group = "spatial-office"; description = "Spatial Office service user"; }; users.groups.spatial-office = { }; # ── Nginx reverse proxy to serve frontend static files ── services.nginx = { enable = true; virtualHosts."n97" = lib.mkMerge [ (lib.mkIf (config.services.nginx.virtualHosts ? "n97") { }) { listen = lib.mkIf (config.services.nginx.virtualHosts ? "n97") [ ]; } ]; virtualHosts."spatial-office" = { listen = [{ addr = "0.0.0.0"; port = cfg.port; }]; root = cfg.frontendDir; locations."/" = { index = "index.html"; tryFiles = "$uri $uri/ /index.html"; }; locations."/ws" = { proxyPass = "http://127.0.0.1:${toString (cfg.port + 1)}"; proxyWebsockets = true; }; }; }; # Firewall — open the frontend port networking.firewall.allowedTCPPorts = [ cfg.port ]; }; }; # ── Alias: spatial-office.nix module (just the systemd service, no nginx) ── spatial-office-service = { config, lib, pkgs, ... }: let cfg = config.services.spatial-office; in { options.services.spatial-office = { enable = lib.mkEnableOption "Spatial Office service"; port = lib.mkOption { type = lib.types.int; default = 18790; }; openclawTokenFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; }; frontendDir = lib.mkOption { type = lib.types.path; default = "${self.packages.${system}.frontend}"; }; logLevel = lib.mkOption { type = lib.types.enum [ "debug" "info" "warn" "error" ]; default = "info"; }; }; config = lib.mkIf cfg.enable { systemd.services.spatial-office = { description = "Spatial Office Backend"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; environment = { PORT = toString cfg.port; SPATIAL_OFFICE_FRONTEND_DIR = cfg.frontendDir; LOG_LEVEL = cfg.logLevel; NODE_ENV = "production"; OPENCLAW_GATEWAY_URL = "ws://127.0.0.1:18789"; } // (lib.optionalAttrs (cfg.openclawTokenFile != null) { OPENCLAW_TOKEN_FILE = cfg.openclawTokenFile; }); serviceConfig = { ExecStart = "${self.packages.${system}.backend}/bin/spatial-office-backend"; Restart = "always"; RestartSec = 5; User = "spatial-office"; Group = "spatial-office"; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; }; }; users.users.spatial-office = { isSystemUser = true; group = "spatial-office"; }; users.groups.spatial-office = { }; networking.firewall.allowedTCPPorts = [ cfg.port ]; }; }; }; } ); }