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 main 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}