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