WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at feat/atb-60-theme-import-export 335 lines 12 kB view raw
1self: 2 3{ config, lib, pkgs, ... }: 4 5let 6 cfg = config.services.atbb; 7 nodejs = pkgs.nodejs_22; 8in 9{ 10 options.services.atbb = { 11 enable = lib.mkEnableOption "atBB forum"; 12 13 package = lib.mkOption { 14 type = lib.types.package; 15 default = self.packages.${pkgs.system}.default; 16 defaultText = lib.literalExpression "self.packages.\${pkgs.system}.default"; 17 description = "The atBB package to use."; 18 }; 19 20 domain = lib.mkOption { 21 type = lib.types.str; 22 description = "Domain name for the forum (e.g., forum.example.com)."; 23 }; 24 25 enableNginx = lib.mkOption { 26 type = lib.types.bool; 27 default = true; 28 description = "Whether to configure nginx as a reverse proxy."; 29 }; 30 31 enableACME = lib.mkOption { 32 type = lib.types.bool; 33 default = true; 34 description = "Whether to enable ACME (Let's Encrypt) for TLS."; 35 }; 36 37 oauthPublicUrl = lib.mkOption { 38 type = lib.types.str; 39 default = "https://${cfg.domain}"; 40 defaultText = lib.literalExpression ''"https://\${cfg.domain}"''; 41 description = "Public URL for OAuth client metadata. Defaults to https://<domain>."; 42 }; 43 44 forumDid = lib.mkOption { 45 type = lib.types.str; 46 description = "The forum's AT Protocol DID."; 47 }; 48 49 pdsUrl = lib.mkOption { 50 type = lib.types.str; 51 description = "URL of the forum's PDS."; 52 }; 53 54 environmentFile = lib.mkOption { 55 type = lib.types.path; 56 description = '' 57 Path to an environment file containing secrets. 58 Must define: DATABASE_URL, SESSION_SECRET, FORUM_HANDLE, FORUM_PASSWORD. 59 When database.enable = true, DATABASE_URL should be: 60 postgres:///atbb?host=/run/postgresql (peer auth via Unix socket) 61 ''; 62 }; 63 64 database = { 65 type = lib.mkOption { 66 type = lib.types.enum [ "postgresql" "sqlite" ]; 67 default = "postgresql"; 68 description = "Database backend. Use 'sqlite' for embedded single-file storage without a separate PostgreSQL service."; 69 }; 70 71 path = lib.mkOption { 72 type = lib.types.path; 73 default = "/var/lib/atbb/atbb.db"; 74 description = "Path to the SQLite database file. Only used when database.type = \"sqlite\"."; 75 }; 76 77 enable = lib.mkOption { 78 type = lib.types.bool; 79 default = cfg.database.type == "postgresql"; 80 description = "Enable local PostgreSQL 17 service. Ignored when database.type = \"sqlite\"."; 81 }; 82 83 name = lib.mkOption { 84 type = lib.types.str; 85 default = "atbb"; 86 description = "Name of the PostgreSQL database."; 87 }; 88 }; 89 90 appviewPort = lib.mkOption { 91 type = lib.types.port; 92 default = 3000; 93 description = "Port for the appview API server (internal, behind nginx)."; 94 }; 95 96 webPort = lib.mkOption { 97 type = lib.types.port; 98 default = 3001; 99 description = "Port for the web UI server (internal, behind nginx)."; 100 }; 101 102 seedDefaultRoles = lib.mkOption { 103 type = lib.types.bool; 104 default = true; 105 description = "Whether to seed default roles on appview startup."; 106 }; 107 108 autoMigrate = lib.mkOption { 109 type = lib.types.bool; 110 default = false; 111 description = '' 112 Whether to automatically run database migrations before starting appview. 113 When false, run migrations manually: systemctl start atbb-migrate 114 ''; 115 }; 116 117 user = lib.mkOption { 118 type = lib.types.str; 119 default = "atbb"; 120 description = "System user to run atBB services."; 121 }; 122 123 group = lib.mkOption { 124 type = lib.types.str; 125 default = "atbb"; 126 description = "System group to run atBB services."; 127 }; 128 }; 129 130 config = lib.mkIf cfg.enable { 131 # ── Assertions ─────────────────────────────────────────────── 132 assertions = [ 133 { 134 assertion = !cfg.database.enable || cfg.user == cfg.database.name; 135 message = '' 136 services.atbb: When database.enable is true, the user name must match 137 the database name for ensureDBOwnership to work. Current values: 138 user = "${cfg.user}", database.name = "${cfg.database.name}". 139 Set both to the same value, or use database.enable = false and manage 140 PostgreSQL manually. 141 ''; 142 } 143 { 144 assertion = !cfg.enableACME 145 || (config.security.acme.acceptTerms 146 && config.security.acme.defaults.email != ""); 147 message = '' 148 services.atbb: enableACME requires security.acme.acceptTerms = true 149 and security.acme.defaults.email to be set. Example: 150 security.acme.acceptTerms = true; 151 security.acme.defaults.email = "admin@example.com"; 152 ''; 153 } 154 ]; 155 156 # ── CLI on system PATH ─────────────────────────────────────── 157 # Makes `atbb` available to all users so administrators can run 158 # setup and management commands (atbb init, atbb category add, etc.) 159 environment.systemPackages = [ cfg.package ]; 160 161 # ── System user ────────────────────────────────────────────── 162 users.users.${cfg.user} = { 163 isSystemUser = true; 164 group = cfg.group; 165 description = "atBB service user"; 166 }; 167 users.groups.${cfg.group} = { }; 168 169 # ── PostgreSQL ─────────────────────────────────────────────── 170 services.postgresql = lib.mkIf (cfg.database.type == "postgresql" && cfg.database.enable) { 171 enable = true; 172 package = pkgs.postgresql_17; 173 ensureDatabases = [ cfg.database.name ]; 174 ensureUsers = [{ 175 name = cfg.user; 176 ensureDBOwnership = true; 177 }]; 178 }; 179 180 # ── Database migration (oneshot) ───────────────────────────── 181 systemd.services.atbb-migrate = { 182 description = "atBB database migration"; 183 after = [ "network.target" ] 184 ++ lib.optional cfg.database.enable "postgresql.service"; 185 requires = lib.optional cfg.database.enable "postgresql.service"; 186 187 # pnpm .bin/ shims are shell scripts that call `node` by name in their 188 # body. patchShebangs only patches the shebang line, leaving the body's 189 # `node` invocation as a PATH lookup. The `path` option prepends 190 # packages to the service PATH without conflicting with NixOS defaults. 191 path = [ nodejs ]; 192 193 environment = lib.optionalAttrs cfg.database.enable { 194 # PGHOST tells postgres.js / drizzle-kit to use the Unix socket 195 # directory rather than relying on ?host= URL query param parsing. 196 PGHOST = "/run/postgresql"; 197 }; 198 199 serviceConfig = { 200 Type = "oneshot"; 201 User = cfg.user; 202 Group = cfg.group; 203 WorkingDirectory = "${cfg.package}/apps/appview"; 204 ExecStart = if cfg.database.type == "sqlite" 205 then "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate --config=drizzle.sqlite.config.ts" 206 else "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate --config=drizzle.postgres.config.ts"; 207 EnvironmentFile = cfg.environmentFile; 208 RemainAfterExit = true; 209 210 # Hardening 211 NoNewPrivileges = true; 212 ProtectSystem = "strict"; 213 ProtectHome = true; 214 PrivateTmp = true; 215 PrivateDevices = true; 216 ProtectKernelTunables = true; 217 ProtectKernelModules = true; 218 ProtectControlGroups = true; 219 RestrictSUIDSGID = true; 220 }; 221 }; 222 223 # ── AppView API server ─────────────────────────────────────── 224 systemd.services.atbb-appview = { 225 description = "atBB AppView API server"; 226 after = [ "network.target" ] 227 ++ lib.optional cfg.database.enable "postgresql.service" 228 ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; 229 requires = lib.optionals cfg.database.enable [ "postgresql.service" ] 230 ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; 231 wantedBy = [ "multi-user.target" ]; 232 233 environment = { 234 NODE_ENV = "production"; 235 PORT = toString cfg.appviewPort; 236 FORUM_DID = cfg.forumDid; 237 PDS_URL = cfg.pdsUrl; 238 OAUTH_PUBLIC_URL = cfg.oauthPublicUrl; 239 SEED_DEFAULT_ROLES = lib.boolToString cfg.seedDefaultRoles; 240 } // lib.optionalAttrs (cfg.database.type == "sqlite") { 241 # SQLite: set DATABASE_URL from module config (not env file) 242 DATABASE_URL = "file:${cfg.database.path}"; 243 } // lib.optionalAttrs (cfg.database.type == "postgresql" && cfg.database.enable) { 244 # Explicit socket directory so postgres.js uses Unix peer auth 245 # regardless of how it parses the DATABASE_URL host parameter. 246 PGHOST = "/run/postgresql"; 247 }; 248 249 serviceConfig = { 250 Type = "simple"; 251 User = cfg.user; 252 Group = cfg.group; 253 WorkingDirectory = "${cfg.package}/apps/appview"; 254 ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/appview/dist/index.js"; 255 EnvironmentFile = cfg.environmentFile; 256 Restart = "on-failure"; 257 RestartSec = 5; 258 259 # SQLite: create /var/lib/atbb/ and grant write access to the service user 260 StateDirectory = lib.mkIf (cfg.database.type == "sqlite") "atbb"; 261 262 # Hardening 263 NoNewPrivileges = true; 264 ProtectSystem = "strict"; 265 ProtectHome = true; 266 PrivateTmp = true; 267 PrivateDevices = true; 268 ProtectKernelTunables = true; 269 ProtectKernelModules = true; 270 ProtectControlGroups = true; 271 RestrictSUIDSGID = true; 272 }; 273 }; 274 275 # ── Web UI server ──────────────────────────────────────────── 276 systemd.services.atbb-web = { 277 description = "atBB Web UI server"; 278 after = [ "network.target" "atbb-appview.service" ]; 279 requires = [ "atbb-appview.service" ]; 280 wantedBy = [ "multi-user.target" ]; 281 282 environment = { 283 NODE_ENV = "production"; 284 WEB_PORT = toString cfg.webPort; 285 APPVIEW_URL = "http://localhost:${toString cfg.appviewPort}"; 286 }; 287 288 serviceConfig = { 289 Type = "simple"; 290 User = cfg.user; 291 Group = cfg.group; 292 WorkingDirectory = "${cfg.package}/apps/web"; 293 ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/web/dist/index.js"; 294 Restart = "on-failure"; 295 RestartSec = 5; 296 297 # Hardening 298 NoNewPrivileges = true; 299 ProtectSystem = "strict"; 300 ProtectHome = true; 301 PrivateTmp = true; 302 PrivateDevices = true; 303 ProtectKernelTunables = true; 304 ProtectKernelModules = true; 305 ProtectControlGroups = true; 306 RestrictSUIDSGID = true; 307 }; 308 }; 309 310 # ── Nginx reverse proxy ────────────────────────────────────── 311 services.nginx = lib.mkIf cfg.enableNginx { 312 enable = true; 313 recommendedProxySettings = true; 314 recommendedTlsSettings = true; 315 recommendedOptimisation = true; 316 317 virtualHosts.${cfg.domain} = { 318 forceSSL = cfg.enableACME; 319 enableACME = cfg.enableACME; 320 321 locations."/.well-known/" = { 322 proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; 323 }; 324 325 locations."/api/" = { 326 proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; 327 }; 328 329 locations."/" = { 330 proxyPass = "http://127.0.0.1:${toString cfg.webPort}"; 331 }; 332 }; 333 }; 334 }; 335}