{ config , lib , pkgs , ... }: let cfg = config.services.grist; in { options.services.grist = { enable = lib.mkEnableOption "Grist Core self-hosted spreadsheet server"; package = lib.mkPackageOption pkgs "grist-core" { }; port = lib.mkOption { type = lib.types.port; default = 8484; description = "Port on which Grist listens."; }; dataDir = lib.mkOption { type = lib.types.path; default = "/var/lib/grist"; description = "Directory for persistent data (documents, home database, etc.). Equivalent to /persist in Docker."; }; sandboxFlavor = lib.mkOption { type = lib.types.enum [ "unsandboxed" "gvisor" "pyodide" "macSandboxExec" ]; default = "unsandboxed"; description = '' Sandbox flavor for executing Python formulas. - unsandboxed: No sandbox (fastest, least secure) - gvisor: gVisor sandbox (Linux only, most secure) - pyodide: WebAssembly sandbox (cross-platform, experimental) - macSandboxExec: macOS native sandbox ''; }; singleOrg = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "mycompany"; description = "If set, pins Grist to a single organization (simplifies UI, common for self-hosted)."; }; defaultEmail = lib.mkOption { type = lib.types.str; default = "you@example.com"; description = "Default admin email used for initial login."; }; host = lib.mkOption { type = lib.types.str; default = "0.0.0.0"; description = "Host interface to bind (0.0.0.0 for all interfaces)."; }; orgInPath = lib.mkOption { type = lib.types.bool; default = true; description = "Include organization in URL path (Docker default)."; }; serveSameOrigin = lib.mkOption { type = lib.types.bool; default = true; description = "Serve home and docs on the same origin/port (Docker default)."; }; openFirewall = lib.mkOption { type = lib.types.bool; default = false; description = "Open the Grist port in the firewall."; }; environment = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; description = "Additional environment variables to pass to Grist."; }; environmentFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = "File containing environment variables (e.g., for secrets like GRIST_SESSION_SECRET)."; }; }; config = lib.mkIf cfg.enable { systemd.services.grist = { description = "Grist Core Spreadsheet Server"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; environment = { PORT = toString cfg.port; GRIST_HOST = cfg.host; GRIST_SINGLE_PORT = if cfg.serveSameOrigin then "true" else "false"; GRIST_ORG_IN_PATH = if cfg.orgInPath then "true" else "false"; GRIST_DATA_DIR = "${cfg.dataDir}/docs"; GRIST_INST_DIR = cfg.dataDir; GRIST_SESSION_COOKIE = "grist_core"; GRIST_SANDBOX_FLAVOR = cfg.sandboxFlavor; NODE_OPTIONS = "--no-deprecation"; NODE_ENV = "production"; TYPEORM_DATABASE = "${cfg.dataDir}/home.sqlite3"; } // lib.optionalAttrs (cfg.singleOrg != null) { GRIST_SINGLE_ORG = cfg.singleOrg; GRIST_DEFAULT_EMAIL = cfg.defaultEmail; } // cfg.environment; serviceConfig = { ExecStart = "${lib.getExe cfg.package}"; DynamicUser = true; StateDirectory = "grist"; StateDirectoryMode = "0700"; WorkingDirectory = cfg.dataDir; EnvironmentFile = lib.optionalString (cfg.environmentFile != null) cfg.environmentFile; Restart = "always"; # Hardening ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; NoNewPrivileges = true; RestrictSUIDSGID = true; }; preStart = '' # Ensure data directories exist with correct ownership mkdir -p ${cfg.dataDir}/docs chown -R $USER:$GROUP ${cfg.dataDir} ''; }; networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ]; }; # Optional: expose the package for passthru.tests or manual use environment.systemPackages = [ cfg.package ]; }; }