self: { config, lib, pkgs, ... }: let cfg = config.services.atbb; nodejs = pkgs.nodejs_22; in { options.services.atbb = { enable = lib.mkEnableOption "atBB forum"; package = lib.mkOption { type = lib.types.package; default = self.packages.${pkgs.system}.default; defaultText = lib.literalExpression "self.packages.\${pkgs.system}.default"; description = "The atBB package to use."; }; domain = lib.mkOption { type = lib.types.str; description = "Domain name for the forum (e.g., forum.example.com)."; }; enableNginx = lib.mkOption { type = lib.types.bool; default = true; description = "Whether to configure nginx as a reverse proxy."; }; enableACME = lib.mkOption { type = lib.types.bool; default = true; description = "Whether to enable ACME (Let's Encrypt) for TLS."; }; oauthPublicUrl = lib.mkOption { type = lib.types.str; default = "https://${cfg.domain}"; defaultText = lib.literalExpression ''"https://\${cfg.domain}"''; description = "Public URL for OAuth client metadata. Defaults to https://."; }; forumDid = lib.mkOption { type = lib.types.str; description = "The forum's AT Protocol DID."; }; pdsUrl = lib.mkOption { type = lib.types.str; description = "URL of the forum's PDS."; }; environmentFile = lib.mkOption { type = lib.types.path; description = '' Path to an environment file containing secrets. Must define: DATABASE_URL, SESSION_SECRET, FORUM_HANDLE, FORUM_PASSWORD. When database.enable = true, DATABASE_URL should be: postgres:///atbb?host=/run/postgresql (peer auth via Unix socket) ''; }; database = { type = lib.mkOption { type = lib.types.enum [ "postgresql" "sqlite" ]; default = "postgresql"; description = "Database backend. Use 'sqlite' for embedded single-file storage without a separate PostgreSQL service."; }; path = lib.mkOption { type = lib.types.path; default = "/var/lib/atbb/atbb.db"; description = "Path to the SQLite database file. Only used when database.type = \"sqlite\"."; }; enable = lib.mkOption { type = lib.types.bool; default = cfg.database.type == "postgresql"; description = "Enable local PostgreSQL 17 service. Ignored when database.type = \"sqlite\"."; }; name = lib.mkOption { type = lib.types.str; default = "atbb"; description = "Name of the PostgreSQL database."; }; }; appviewPort = lib.mkOption { type = lib.types.port; default = 3000; description = "Port for the appview API server (internal, behind nginx)."; }; webPort = lib.mkOption { type = lib.types.port; default = 3001; description = "Port for the web UI server (internal, behind nginx)."; }; seedDefaultRoles = lib.mkOption { type = lib.types.bool; default = true; description = "Whether to seed default roles on appview startup."; }; autoMigrate = lib.mkOption { type = lib.types.bool; default = false; description = '' Whether to automatically run database migrations before starting appview. When false, run migrations manually: systemctl start atbb-migrate ''; }; user = lib.mkOption { type = lib.types.str; default = "atbb"; description = "System user to run atBB services."; }; group = lib.mkOption { type = lib.types.str; default = "atbb"; description = "System group to run atBB services."; }; }; config = lib.mkIf cfg.enable { # ── Assertions ─────────────────────────────────────────────── assertions = [ { assertion = !cfg.database.enable || cfg.user == cfg.database.name; message = '' services.atbb: When database.enable is true, the user name must match the database name for ensureDBOwnership to work. Current values: user = "${cfg.user}", database.name = "${cfg.database.name}". Set both to the same value, or use database.enable = false and manage PostgreSQL manually. ''; } { assertion = !cfg.enableACME || (config.security.acme.acceptTerms && config.security.acme.defaults.email != ""); message = '' services.atbb: enableACME requires security.acme.acceptTerms = true and security.acme.defaults.email to be set. Example: security.acme.acceptTerms = true; security.acme.defaults.email = "admin@example.com"; ''; } ]; # ── CLI on system PATH ─────────────────────────────────────── # Makes `atbb` available to all users so administrators can run # setup and management commands (atbb init, atbb category add, etc.) environment.systemPackages = [ cfg.package ]; # ── System user ────────────────────────────────────────────── users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; description = "atBB service user"; }; users.groups.${cfg.group} = { }; # ── PostgreSQL ─────────────────────────────────────────────── services.postgresql = lib.mkIf (cfg.database.type == "postgresql" && cfg.database.enable) { enable = true; package = pkgs.postgresql_17; ensureDatabases = [ cfg.database.name ]; ensureUsers = [{ name = cfg.user; ensureDBOwnership = true; }]; }; # ── Database migration (oneshot) ───────────────────────────── systemd.services.atbb-migrate = { description = "atBB database migration"; after = [ "network.target" ] ++ lib.optional cfg.database.enable "postgresql.service"; requires = lib.optional cfg.database.enable "postgresql.service"; # pnpm .bin/ shims are shell scripts that call `node` by name in their # body. patchShebangs only patches the shebang line, leaving the body's # `node` invocation as a PATH lookup. The `path` option prepends # packages to the service PATH without conflicting with NixOS defaults. path = [ nodejs ]; environment = lib.optionalAttrs cfg.database.enable { # PGHOST tells postgres.js / drizzle-kit to use the Unix socket # directory rather than relying on ?host= URL query param parsing. PGHOST = "/run/postgresql"; }; serviceConfig = { Type = "oneshot"; User = cfg.user; Group = cfg.group; WorkingDirectory = "${cfg.package}/apps/appview"; ExecStart = if cfg.database.type == "sqlite" then "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate --config=drizzle.sqlite.config.ts" else "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate --config=drizzle.postgres.config.ts"; EnvironmentFile = cfg.environmentFile; RemainAfterExit = true; # Hardening NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictSUIDSGID = true; }; }; # ── AppView API server ─────────────────────────────────────── systemd.services.atbb-appview = { description = "atBB AppView API server"; after = [ "network.target" ] ++ lib.optional cfg.database.enable "postgresql.service" ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; requires = lib.optionals cfg.database.enable [ "postgresql.service" ] ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; wantedBy = [ "multi-user.target" ]; environment = { NODE_ENV = "production"; PORT = toString cfg.appviewPort; FORUM_DID = cfg.forumDid; PDS_URL = cfg.pdsUrl; OAUTH_PUBLIC_URL = cfg.oauthPublicUrl; SEED_DEFAULT_ROLES = lib.boolToString cfg.seedDefaultRoles; } // lib.optionalAttrs (cfg.database.type == "sqlite") { # SQLite: set DATABASE_URL from module config (not env file) DATABASE_URL = "file:${cfg.database.path}"; } // lib.optionalAttrs (cfg.database.type == "postgresql" && cfg.database.enable) { # Explicit socket directory so postgres.js uses Unix peer auth # regardless of how it parses the DATABASE_URL host parameter. PGHOST = "/run/postgresql"; }; serviceConfig = { Type = "simple"; User = cfg.user; Group = cfg.group; WorkingDirectory = "${cfg.package}/apps/appview"; ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/appview/dist/index.js"; EnvironmentFile = cfg.environmentFile; Restart = "on-failure"; RestartSec = 5; # SQLite: create /var/lib/atbb/ and grant write access to the service user StateDirectory = lib.mkIf (cfg.database.type == "sqlite") "atbb"; # Hardening NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictSUIDSGID = true; }; }; # ── Web UI server ──────────────────────────────────────────── systemd.services.atbb-web = { description = "atBB Web UI server"; after = [ "network.target" "atbb-appview.service" ]; requires = [ "atbb-appview.service" ]; wantedBy = [ "multi-user.target" ]; environment = { NODE_ENV = "production"; WEB_PORT = toString cfg.webPort; APPVIEW_URL = "http://localhost:${toString cfg.appviewPort}"; }; serviceConfig = { Type = "simple"; User = cfg.user; Group = cfg.group; WorkingDirectory = "${cfg.package}/apps/web"; ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/web/dist/index.js"; Restart = "on-failure"; RestartSec = 5; # Hardening NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictSUIDSGID = true; }; }; # ── Nginx reverse proxy ────────────────────────────────────── services.nginx = lib.mkIf cfg.enableNginx { enable = true; recommendedProxySettings = true; recommendedTlsSettings = true; recommendedOptimisation = true; virtualHosts.${cfg.domain} = { forceSSL = cfg.enableACME; enableACME = cfg.enableACME; locations."/.well-known/" = { proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; }; locations."/api/" = { proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; }; locations."/" = { proxyPass = "http://127.0.0.1:${toString cfg.webPort}"; }; }; }; }; }