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

feat(nix): add NixOS flake for production deployment (#50)

* docs: add NixOS flake design for atBB deployment

NixOS module with systemd services for appview + web, nginx
virtualHost integration, and optional PostgreSQL provisioning.

* docs: add NixOS flake implementation plan

Six-task plan: flake skeleton, package derivation with pnpm.fetchDeps,
NixOS module options, systemd services, nginx virtualHost, final verify.

* chore: scaffold Nix flake with placeholder package and module

* feat(nix): implement package derivation with pnpm workspace build

* feat(nix): add NixOS module option declarations for services.atbb

* feat(nix): add systemd services and PostgreSQL to NixOS module

* feat(nix): add nginx virtualHost with ACME to NixOS module

* fix(nix): address code review findings in NixOS module

- Use drizzle-kit/bin.cjs directly instead of .bin shim
- Add network.target to atbb-web service ordering
- Equalize hardening directives across all services
- Add assertion for ensureDBOwnership name constraint

* chore: add Nix result symlink to .gitignore

* fix(nix): address PR review feedback

- Include packages/db/src/ in package output for drizzle.config.ts
schema path resolution (../../packages/db/src/schema.ts)
- Use .bin/drizzle-kit shim directly instead of node + bin.cjs
(patchShebangs rewrites the shebang, making it self-contained)
- Add requires = [ "atbb-appview.service" ] to atbb-web so it
stops if appview goes down (after alone only orders startup)
- Add ACME prerequisites assertion (acceptTerms + email)

* feat(nix): expose atbb CLI as a bin wrapper

makeWrapper creates $out/bin/atbb pointing at the CLI's dist/index.js
with the Nix store Node binary. This puts `atbb init`, `atbb category`,
and `atbb board` on PATH for the deployed system.

* fix(nix): add fetcherVersion and real pnpmDeps hash

- Add fetcherVersion = 1 (required by updated nixpkgs fetchPnpmDeps API)
- Set real pnpmDeps hash obtained from Linux build via Docker
- Keep pnpm_9.fetchDeps/configHook for consistency; mixing with the
top-level pnpmConfigHook (pnpm 10) caused lefthook to be missing
from the offline store during pnpm install

* fix(nix): use x86_64-linux pnpm deps hash

The previous hash was computed on aarch64-linux (Apple Silicon Docker).
pnpm_9.fetchDeps fetches platform-specific optional packages (e.g.
lefthook-linux-arm64 vs lefthook-linux-x64), so the store content
differs per architecture.

Hash corrected to the x86_64-linux value reported by the Colmena build.

* docs(nix): add example Caddyfile for Caddy users

Provides an alternative to the built-in nginx reverse proxy for operators
who prefer Caddy. Includes:
- Correct routing for /.well-known/* (must reach appview for AT Proto OAuth)
- /api/* → appview, /* → web UI
- NixOS integration snippet using services.caddy.virtualHosts with
references to services.atbb port options

* docs: add NixOS deployment section to deployment guide

Covers the full NixOS deployment path as an alternative to Docker:
- Adding the atBB flake as a nixosModules.default input
- Creating the environment file with Unix socket DATABASE_URL
- Module configuration with key options reference table
- Running atbb-migrate one-shot service
- Caddy alternative to built-in nginx (referencing Caddyfile.example)
- Upgrade procedure via nix flake update

* feat(nix): add atbb CLI to environment.systemPackages

The atbb binary was built into the package but not put on PATH.
Adding cfg.package to systemPackages makes `atbb init`, `atbb category`,
and `atbb board` available to all users after nixos-rebuild switch.

* fix(nix): set PGHOST explicitly for Unix socket connections

postgres.js does not reliably honour ?host= as a query parameter in
connection string URLs, causing it to fall back to TCP (127.0.0.1:5432)
and triggering md5 password auth instead of peer auth.

Setting PGHOST=/run/postgresql in all systemd service environments and
in the env file template ensures postgres.js uses the Unix socket
directory directly, regardless of URL parsing behaviour.

* fix(nix): add nodejs to PATH for atbb-migrate service

pnpm .bin/ shims are shell scripts that invoke `node` by name in their
body. patchShebangs only patches the shebang line, leaving the body's
`node` call as a PATH lookup. Systemd's default PATH excludes the Nix
store, so drizzle-kit fails with "node not found".

Setting PATH=${nodejs}/bin in the service environment resolves this.

* fix(nix): use path option instead of environment.PATH for nodejs

Setting environment.PATH conflicts with NixOS's default systemd PATH
definition. The `path` option is the NixOS-idiomatic way to add
packages to a service's PATH — it prepends package bin dirs without
replacing the system defaults.

authored by

Malpercio and committed by
GitHub
7730ec8e 939b361e

+1595 -2
+3
.gitignore
··· 37 37 38 38 # Test artifacts 39 39 test-output.txt 40 + 41 + # Nix build output 42 + result
+231 -2
docs/deployment-guide.md
··· 1 1 # atBB Deployment Guide 2 2 3 - **Version:** 1.0 4 - **Last Updated:** 2026-02-12 3 + **Version:** 1.1 4 + **Last Updated:** 2026-02-22 5 5 **Audience:** System administrators deploying atBB to production 6 6 7 7 > **Related Documentation:** ··· 20 20 8. [Upgrading](#8-upgrading) 21 21 9. [Troubleshooting](#9-troubleshooting) 22 22 10. [Docker Compose Example](#10-docker-compose-example) 23 + 11. [NixOS Deployment](#11-nixos-deployment) 23 24 24 25 --- 25 26 ··· 1767 1768 - [ ] Set up health monitoring 1768 1769 - [ ] Restricted firewall to ports 80/443 only 1769 1770 - [ ] Tested backup restoration procedure 1771 + 1772 + --- 1773 + 1774 + ## 11. NixOS Deployment 1775 + 1776 + The atBB flake provides a NixOS module that manages all services declaratively: 1777 + 1778 + - **`atbb-appview`** — Hono API server (systemd service) 1779 + - **`atbb-web`** — Hono web UI server (systemd service) 1780 + - **`atbb-migrate`** — One-shot database migration service 1781 + - **PostgreSQL 17** — Local database with peer authentication (optional) 1782 + - **nginx** — Reverse proxy with automatic ACME/Let's Encrypt TLS (optional) 1783 + 1784 + The module is suitable for single-server deployments. Sections 1–10 of this guide describe Docker-based deployment; this section covers the NixOS path exclusively. 1785 + 1786 + ### Step 1: Add atBB as a Flake Input 1787 + 1788 + In your NixOS system flake: 1789 + 1790 + ```nix 1791 + { 1792 + inputs = { 1793 + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 1794 + atbb.url = "github:malpercio-dev/atbb-monorepo"; 1795 + }; 1796 + 1797 + outputs = { nixpkgs, atbb, ... }: { 1798 + nixosConfigurations.my-server = nixpkgs.lib.nixosSystem { 1799 + system = "x86_64-linux"; 1800 + modules = [ 1801 + atbb.nixosModules.default 1802 + ./configuration.nix 1803 + ]; 1804 + }; 1805 + }; 1806 + } 1807 + ``` 1808 + 1809 + > **Note:** The module is exported as `nixosModules.default`, not `nixosModules.atbb`. 1810 + 1811 + ### Step 2: Create the Environment File 1812 + 1813 + The module reads secrets from an environment file on the server (never bake secrets into the Nix store). Create the file at a path of your choosing — `/etc/atbb/env` is a reasonable default: 1814 + 1815 + ```bash 1816 + sudo mkdir -p /etc/atbb 1817 + sudo tee /etc/atbb/env > /dev/null <<'EOF' 1818 + # Database — Unix socket peer auth (matches services.atbb.user = "atbb") 1819 + DATABASE_URL=postgres:///atbb?host=/run/postgresql 1820 + # PGHOST makes postgres.js use the Unix socket directory reliably, 1821 + # since it does not always honour the ?host= query parameter in URLs. 1822 + PGHOST=/run/postgresql 1823 + 1824 + # Session security 1825 + SESSION_SECRET=<generate with: openssl rand -hex 32> 1826 + 1827 + # Forum AT Protocol account credentials 1828 + FORUM_HANDLE=forum.example.com 1829 + FORUM_PASSWORD=<your forum account password> 1830 + EOF 1831 + 1832 + sudo chmod 600 /etc/atbb/env 1833 + ``` 1834 + 1835 + **Why Unix socket for `DATABASE_URL`?** 1836 + When `database.enable = true` (the default), the module creates a local PostgreSQL 17 instance and configures peer authentication. Peer auth maps the OS user name to the database user name — no password needed. The connection string `postgres:///atbb?host=/run/postgresql` says: connect to the `atbb` database via the Unix socket at `/run/postgresql`, as the current OS user (`atbb`). 1837 + 1838 + **Secrets management:** For automated deployments, consider [sops-nix](https://github.com/Mic92/sops-nix) or [agenix](https://github.com/ryantm/agenix) to provision `/etc/atbb/env` as an encrypted secret rather than managing it manually. 1839 + 1840 + ### Step 3: Configure the Module 1841 + 1842 + Add to your `configuration.nix`: 1843 + 1844 + ```nix 1845 + { 1846 + services.atbb = { 1847 + enable = true; 1848 + domain = "forum.example.com"; 1849 + forumDid = "did:plc:your-forum-did"; 1850 + pdsUrl = "https://bsky.social"; 1851 + 1852 + # Path to the environment file created in Step 2 1853 + environmentFile = /etc/atbb/env; 1854 + 1855 + # Local PostgreSQL (default: true) 1856 + # Set to false to use an external database via DATABASE_URL 1857 + database.enable = true; 1858 + 1859 + # Run migrations manually after each deploy (safer) 1860 + # Set to true to run automatically on every appview start 1861 + autoMigrate = false; 1862 + }; 1863 + 1864 + # Required when enableACME = true (the default) 1865 + security.acme = { 1866 + acceptTerms = true; 1867 + defaults.email = "admin@example.com"; 1868 + }; 1869 + } 1870 + ``` 1871 + 1872 + **Important:** When `database.enable = true`, the system user name (`services.atbb.user`, default `"atbb"`) must match the database name (`services.atbb.database.name`, default `"atbb"`). PostgreSQL peer authentication requires this. The module enforces this with an assertion — if you change either value, change both to match. 1873 + 1874 + #### Key Options Reference 1875 + 1876 + | Option | Default | Description | 1877 + |--------|---------|-------------| 1878 + | `domain` | *(required)* | Public domain for the forum | 1879 + | `forumDid` | *(required)* | Forum's AT Protocol DID | 1880 + | `pdsUrl` | *(required)* | URL of the forum's PDS | 1881 + | `environmentFile` | *(required)* | Path to secrets file | 1882 + | `database.enable` | `true` | Provision local PostgreSQL 17 | 1883 + | `database.name` | `"atbb"` | Database name | 1884 + | `autoMigrate` | `false` | Run migrations on appview start | 1885 + | `enableNginx` | `true` | Configure nginx reverse proxy | 1886 + | `enableACME` | `true` | Enable Let's Encrypt TLS | 1887 + | `appviewPort` | `3000` | Internal port for appview | 1888 + | `webPort` | `3001` | Internal port for web UI | 1889 + | `user` / `group` | `"atbb"` | System user/group for services | 1890 + 1891 + ### Step 4: Deploy 1892 + 1893 + Apply your configuration using your preferred NixOS deployment tool: 1894 + 1895 + ```bash 1896 + # Local rebuild 1897 + sudo nixos-rebuild switch --flake .#my-server 1898 + 1899 + # Remote via colmena 1900 + colmena apply --on my-server 1901 + 1902 + # Remote via nixos-rebuild 1903 + nixos-rebuild switch --flake .#my-server \ 1904 + --target-host root@forum.example.com 1905 + ``` 1906 + 1907 + ### Step 5: Run Database Migrations 1908 + 1909 + The `atbb-migrate` service is a one-shot systemd unit — it runs once and exits. Trigger it manually after each deployment: 1910 + 1911 + ```bash 1912 + sudo systemctl start atbb-migrate 1913 + 1914 + # Check migration output 1915 + sudo journalctl -u atbb-migrate 1916 + ``` 1917 + 1918 + **Expected output:** 1919 + ``` 1920 + Reading migrations from /nix/store/.../apps/appview/drizzle 1921 + Applying migration: 0000_initial_schema.sql 1922 + ... 1923 + All migrations applied successfully 1924 + ``` 1925 + 1926 + If you prefer migrations to run automatically on every appview start, set `autoMigrate = true`. Be aware this adds startup latency and prevents appview from starting if migrations fail. 1927 + 1928 + ### Step 6: Verify Services 1929 + 1930 + ```bash 1931 + # Check all atBB services 1932 + systemctl status atbb-appview atbb-web 1933 + 1934 + # View live logs 1935 + journalctl -fu atbb-appview 1936 + journalctl -fu atbb-web 1937 + 1938 + # Test the API 1939 + curl http://localhost:3000/api/healthz 1940 + # Expected: {"status":"ok"} 1941 + 1942 + # Verify nginx is routing correctly 1943 + curl https://forum.example.com/api/healthz 1944 + ``` 1945 + 1946 + ### Using Caddy Instead of nginx 1947 + 1948 + If you prefer Caddy, disable the built-in nginx proxy and configure `services.caddy` yourself: 1949 + 1950 + ```nix 1951 + { 1952 + services.atbb = { 1953 + # ... other options 1954 + enableNginx = false; # disable built-in nginx virtualHost 1955 + enableACME = false; # Caddy manages TLS automatically 1956 + }; 1957 + 1958 + services.caddy = { 1959 + enable = true; 1960 + virtualHosts."forum.example.com".extraConfig = '' 1961 + # AT Protocol well-known endpoints → appview 1962 + # Must reach appview (not web UI) for OAuth to work 1963 + handle /.well-known/* { 1964 + reverse_proxy localhost:${toString config.services.atbb.appviewPort} 1965 + } 1966 + 1967 + # REST API → appview 1968 + handle /api/* { 1969 + reverse_proxy localhost:${toString config.services.atbb.appviewPort} 1970 + } 1971 + 1972 + # Web UI — catch-all 1973 + handle { 1974 + reverse_proxy localhost:${toString config.services.atbb.webPort} 1975 + } 1976 + ''; 1977 + }; 1978 + } 1979 + ``` 1980 + 1981 + See `nix/Caddyfile.example` in the repository for the equivalent standalone Caddyfile. 1982 + 1983 + ### Upgrading 1984 + 1985 + To upgrade atBB, update the flake input and redeploy: 1986 + 1987 + ```bash 1988 + # Update atBB to latest 1989 + nix flake update atbb 1990 + 1991 + # Redeploy 1992 + sudo nixos-rebuild switch --flake .#my-server 1993 + 1994 + # Run migrations for the new version 1995 + sudo systemctl start atbb-migrate 1996 + ``` 1997 + 1998 + NixOS handles the service restart automatically when the package changes. Because `atbb-appview` and `atbb-web` are declared with `Restart = "on-failure"`, a failed startup will not leave broken processes running. 1770 1999 1771 2000 --- 1772 2001
+149
docs/plans/2026-02-20-nixos-flake-design.md
··· 1 + # NixOS Flake Design 2 + 3 + Deploy atBB as a NixOS service, replacing the Docker-based deployment with native NixOS infrastructure. 4 + 5 + ## Flake Exports 6 + 7 + 1. **`packages.${system}.default`** — Nix derivation that builds the atBB monorepo into a self-contained directory with production node_modules and dist artifacts. 8 + 2. **`nixosModules.default`** — NixOS module (`services.atbb`) with systemd services, nginx virtualHost, and optional PostgreSQL provisioning. 9 + 10 + ## Package Derivation 11 + 12 + Uses `stdenv.mkDerivation` with pnpm to: 13 + 14 + 1. Install all dependencies (`pnpm install --frozen-lockfile --ignore-scripts`) 15 + 2. Build all packages (`pnpm build` via turbo: lexicon first, then appview + web) 16 + 3. Prune to production dependencies (`pnpm install --prod --ignore-scripts`) 17 + 4. Copy into `$out`: package.json files, node_modules, dist/, drizzle migrations, web/public/ 18 + 19 + The pnpm store is fetched as a fixed-output derivation for reproducibility. 20 + 21 + ## NixOS Module Options 22 + 23 + ```nix 24 + services.atbb = { 25 + enable = mkEnableOption "atBB forum"; 26 + package = mkOption { default = self.packages.${system}.default; }; 27 + 28 + # Domain & networking 29 + domain = mkOption { type = str; }; 30 + enableNginx = mkOption { default = true; }; 31 + enableACME = mkOption { default = true; }; 32 + 33 + # AT Protocol 34 + forumDid = mkOption { type = str; }; 35 + pdsUrl = mkOption { type = str; }; 36 + 37 + # Secrets (file path, not inline — keeps secrets out of /nix/store) 38 + environmentFile = mkOption { type = path; }; 39 + # Contains: DATABASE_URL, SESSION_SECRET, FORUM_HANDLE, FORUM_PASSWORD 40 + 41 + # PostgreSQL 42 + database = { 43 + enable = mkOption { default = true; }; 44 + name = mkOption { default = "atbb"; }; 45 + }; 46 + 47 + # Internal ports (nginx proxies to these) 48 + appviewPort = mkOption { default = 3000; }; 49 + webPort = mkOption { default = 3001; }; 50 + }; 51 + ``` 52 + 53 + ## Systemd Services 54 + 55 + Two services under a dedicated `atbb` system user: 56 + 57 + ### atbb-appview.service 58 + 59 + - `ExecStart`: `node ${package}/apps/appview/dist/index.js` 60 + - `WorkingDirectory`: `${package}/apps/appview` 61 + - `EnvironmentFile` for secrets (DATABASE_URL, SESSION_SECRET, FORUM_HANDLE, FORUM_PASSWORD) 62 + - `Environment` for non-secret config (PORT, FORUM_DID, PDS_URL, OAUTH_PUBLIC_URL, SEED_DEFAULT_ROLES) 63 + - After/Requires `postgresql.service` when database.enable is true 64 + - `Restart = on-failure`, hardened sandboxing 65 + 66 + ### atbb-web.service 67 + 68 + - `ExecStart`: `node ${package}/apps/web/dist/index.js` 69 + - `WorkingDirectory`: `${package}/apps/web` 70 + - `Environment`: WEB_PORT, APPVIEW_URL (points to localhost:appviewPort) 71 + - After `atbb-appview.service` 72 + - `Restart = on-failure`, hardened sandboxing 73 + 74 + ## Nginx Virtual Host 75 + 76 + Uses NixOS `services.nginx.virtualHosts`: 77 + 78 + ```nix 79 + services.nginx.virtualHosts.${cfg.domain} = { 80 + forceSSL = cfg.enableACME; 81 + enableACME = cfg.enableACME; 82 + locations."/.well-known/".proxyPass = "http://localhost:${toString cfg.appviewPort}"; 83 + locations."/api/".proxyPass = "http://localhost:${toString cfg.appviewPort}"; 84 + locations."/".proxyPass = "http://localhost:${toString cfg.webPort}"; 85 + }; 86 + ``` 87 + 88 + ## PostgreSQL (Optional) 89 + 90 + When `database.enable = true`: 91 + 92 + - Enables `services.postgresql` (PostgreSQL 17) 93 + - Creates database and user via `ensureDatabases` / `ensureUsers` 94 + - Appview service depends on postgresql.service 95 + 96 + When `database.enable = false`: 97 + 98 + - Operator provides DATABASE_URL in the environmentFile 99 + - No PostgreSQL configuration 100 + 101 + ## File Layout 102 + 103 + ``` 104 + flake.nix # Inputs, package derivation, NixOS module export 105 + nix/ 106 + package.nix # atBB package derivation (build + prune) 107 + module.nix # NixOS module (options + systemd + nginx + postgres) 108 + ``` 109 + 110 + ## Example Consumer Configuration 111 + 112 + ```nix 113 + { 114 + inputs.atbb.url = "github:malpercio-dev/atbb-monorepo"; 115 + 116 + outputs = { self, nixpkgs, atbb, ... }: { 117 + nixosConfigurations.myserver = nixpkgs.lib.nixosSystem { 118 + modules = [ 119 + atbb.nixosModules.default 120 + { 121 + services.atbb = { 122 + enable = true; 123 + domain = "forum.example.com"; 124 + forumDid = "did:plc:abc123"; 125 + pdsUrl = "https://pds.example.com"; 126 + environmentFile = "/run/secrets/atbb-env"; 127 + }; 128 + } 129 + ]; 130 + }; 131 + }; 132 + } 133 + ``` 134 + 135 + The `environmentFile` at `/run/secrets/atbb-env` contains: 136 + 137 + ``` 138 + DATABASE_URL=postgresql://atbb:password@localhost:5432/atbb 139 + SESSION_SECRET=<hex-string> 140 + FORUM_HANDLE=forum.example.com 141 + FORUM_PASSWORD=<password> 142 + ``` 143 + 144 + ## Design Decisions 145 + 146 + - **Secrets as file paths**: Inline Nix values end up world-readable in `/nix/store`. `EnvironmentFile` in systemd keeps secrets out of the store. 147 + - **Separate systemd services**: Each process gets independent restart, journald logging, and dependency ordering (vs. Docker's single-entrypoint supervisor). 148 + - **`services.nginx` integration**: NixOS nginx module handles config generation, reload-on-change, and ACME certificate automation with one boolean. 149 + - **`stdenv.mkDerivation` over `buildNpmPackage`**: pnpm workspaces aren't supported by `buildNpmPackage` (which is npm-specific). We use mkDerivation with pnpm directly.
+675
docs/plans/2026-02-20-nixos-flake-implementation.md
··· 1 + # NixOS Flake Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Create a Nix flake that builds the atBB monorepo and defines a NixOS module for deploying appview + web as systemd services behind nginx. 6 + 7 + **Architecture:** The flake exports a package derivation (builds the pnpm monorepo via `pnpm_9.fetchDeps` + `pnpm_9.configHook`) and a NixOS module (`services.atbb`) that wires up two systemd services, an nginx virtualHost with ACME, and optional local PostgreSQL. Secrets stay out of `/nix/store` via systemd `EnvironmentFile`. 8 + 9 + **Tech Stack:** Nix flakes, nixpkgs `pnpm_9.fetchDeps`/`configHook`, NixOS module system, systemd, `services.nginx`, `services.postgresql`. 10 + 11 + **Design doc:** `docs/plans/2026-02-20-nixos-flake-design.md` 12 + 13 + --- 14 + 15 + ### Task 1: Create flake.nix skeleton 16 + 17 + **Files:** 18 + - Create: `flake.nix` 19 + 20 + **Step 1: Write flake.nix** 21 + 22 + ```nix 23 + { 24 + description = "atBB — decentralized BB-style forum on AT Protocol"; 25 + 26 + inputs = { 27 + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 28 + }; 29 + 30 + outputs = { self, nixpkgs }: 31 + let 32 + supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; 33 + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 34 + in 35 + { 36 + packages = forAllSystems (system: 37 + let pkgs = nixpkgs.legacyPackages.${system}; 38 + in { 39 + default = pkgs.callPackage ./nix/package.nix { }; 40 + } 41 + ); 42 + 43 + nixosModules.default = import ./nix/module.nix self; 44 + }; 45 + } 46 + ``` 47 + 48 + **Step 2: Create nix/ directory** 49 + 50 + ```bash 51 + mkdir -p nix 52 + ``` 53 + 54 + **Step 3: Create placeholder nix/package.nix** 55 + 56 + Create `nix/package.nix` with a minimal derivation so `nix flake check` can parse the flake: 57 + 58 + ```nix 59 + # Placeholder — will be implemented in Task 2 60 + { stdenv, ... }: 61 + 62 + stdenv.mkDerivation { 63 + pname = "atbb"; 64 + version = "0.1.0"; 65 + src = ../.; 66 + installPhase = "mkdir -p $out"; 67 + } 68 + ``` 69 + 70 + **Step 4: Create placeholder nix/module.nix** 71 + 72 + Create `nix/module.nix` with a minimal module so the flake parses: 73 + 74 + ```nix 75 + # Placeholder — will be implemented in Task 3 76 + self: 77 + { config, lib, pkgs, ... }: 78 + 79 + { 80 + options.services.atbb = { 81 + enable = lib.mkEnableOption "atBB forum"; 82 + }; 83 + } 84 + ``` 85 + 86 + **Step 5: Verify flake parses** 87 + 88 + ```bash 89 + nix flake check --no-build 90 + ``` 91 + 92 + Expected: No errors. The flake structure is valid. 93 + 94 + **Step 6: Commit** 95 + 96 + ```bash 97 + git add flake.nix nix/package.nix nix/module.nix 98 + git commit -m "chore: scaffold Nix flake with placeholder package and module" 99 + ``` 100 + 101 + --- 102 + 103 + ### Task 2: Implement package derivation 104 + 105 + **Files:** 106 + - Modify: `nix/package.nix` 107 + 108 + The package derivation builds the full pnpm monorepo and copies runtime artifacts to `$out`. 109 + 110 + **Key concepts:** 111 + - `pnpm_9.fetchDeps` creates a fixed-output derivation (FOD) containing the pnpm content-addressable store. This runs with network access. 112 + - `pnpm_9.configHook` is a setup hook that configures pnpm to use the fetched store and runs `pnpm install --offline --frozen-lockfile` in the `configurePhase`. 113 + - After `configurePhase`, the build sandbox has no network — everything is offline. 114 + - pnpm's workspace symlinks point into the Nix store (absolute paths to the FOD), so they survive copying to `$out`. 115 + 116 + **Step 1: Write the full package derivation** 117 + 118 + Replace `nix/package.nix` with: 119 + 120 + ```nix 121 + { 122 + lib, 123 + stdenv, 124 + nodejs_22, 125 + pnpm_9, 126 + bash, 127 + }: 128 + 129 + stdenv.mkDerivation (finalAttrs: { 130 + pname = "atbb"; 131 + version = "0.1.0"; 132 + 133 + src = lib.fileset.toSource { 134 + root = ../.; 135 + fileset = lib.fileset.unions [ 136 + ../package.json 137 + ../pnpm-lock.yaml 138 + ../pnpm-workspace.yaml 139 + ../turbo.json 140 + ../tsconfig.base.json 141 + ../apps 142 + ../packages 143 + ]; 144 + }; 145 + 146 + pnpmDeps = pnpm_9.fetchDeps { 147 + inherit (finalAttrs) pname version src; 148 + # Set to lib.fakeHash initially, then update after first build attempt 149 + hash = lib.fakeHash; 150 + }; 151 + 152 + nativeBuildInputs = [ 153 + nodejs_22 154 + pnpm_9.configHook 155 + bash 156 + ]; 157 + 158 + buildPhase = '' 159 + runHook preBuild 160 + pnpm build 161 + runHook postBuild 162 + ''; 163 + 164 + installPhase = '' 165 + runHook preInstall 166 + 167 + mkdir -p $out 168 + 169 + # Workspace config 170 + cp package.json pnpm-lock.yaml pnpm-workspace.yaml $out/ 171 + 172 + # Root node_modules (pnpm virtual store — symlinks point into Nix store FOD) 173 + cp -r node_modules $out/ 174 + 175 + # Each workspace package: package.json + dist/ + local node_modules symlinks 176 + for pkg in apps/appview apps/web packages/db packages/atproto packages/cli packages/lexicon; do 177 + mkdir -p "$out/$pkg" 178 + cp "$pkg/package.json" "$out/$pkg/" 179 + [ -d "$pkg/dist" ] && cp -r "$pkg/dist" "$out/$pkg/" 180 + [ -d "$pkg/node_modules" ] && cp -r "$pkg/node_modules" "$out/$pkg/" 181 + done 182 + 183 + # Drizzle migrations (needed for db:migrate at deploy time) 184 + cp -r apps/appview/drizzle $out/apps/appview/ 185 + 186 + # drizzle.config.ts (needed by drizzle-kit migrate) 187 + cp apps/appview/drizzle.config.ts $out/apps/appview/ 188 + 189 + # Web static assets (CSS, favicon — served by hono serveStatic) 190 + cp -r apps/web/public $out/apps/web/ 191 + 192 + runHook postInstall 193 + ''; 194 + 195 + meta = with lib; { 196 + description = "atBB — decentralized BB-style forum on AT Protocol"; 197 + license = licenses.agpl3Only; 198 + platforms = [ "x86_64-linux" "aarch64-linux" ]; 199 + }; 200 + }) 201 + ``` 202 + 203 + **Step 2: Build to get the real pnpm hash** 204 + 205 + ```bash 206 + nix build 2>&1 | grep 'got:' 207 + ``` 208 + 209 + Expected: Build fails with a hash mismatch. Copy the `got: sha256-XXXX...` value. 210 + 211 + **Step 3: Update the hash** 212 + 213 + Replace `lib.fakeHash` in `nix/package.nix` with the real hash from step 2: 214 + 215 + ```nix 216 + hash = "sha256-<paste real hash here>"; 217 + ``` 218 + 219 + **Step 4: Build again to verify** 220 + 221 + ```bash 222 + nix build 223 + ``` 224 + 225 + Expected: Build succeeds. Check the output: 226 + 227 + ```bash 228 + ls result/apps/appview/dist/ 229 + ls result/apps/web/dist/ 230 + ls result/apps/appview/drizzle/ 231 + ls result/apps/web/public/ 232 + ``` 233 + 234 + All directories should exist with built artifacts. 235 + 236 + **Step 5: Commit** 237 + 238 + ```bash 239 + git add nix/package.nix 240 + git commit -m "feat(nix): implement package derivation with pnpm workspace build" 241 + ``` 242 + 243 + --- 244 + 245 + ### Task 3: Implement NixOS module — option declarations 246 + 247 + **Files:** 248 + - Modify: `nix/module.nix` 249 + 250 + **Step 1: Write all option declarations** 251 + 252 + Replace `nix/module.nix` with the full options block: 253 + 254 + ```nix 255 + self: 256 + 257 + { config, lib, pkgs, ... }: 258 + 259 + let 260 + cfg = config.services.atbb; 261 + nodejs = pkgs.nodejs_22; 262 + in 263 + { 264 + options.services.atbb = { 265 + enable = lib.mkEnableOption "atBB forum"; 266 + 267 + package = lib.mkOption { 268 + type = lib.types.package; 269 + default = self.packages.${pkgs.system}.default; 270 + defaultText = lib.literalExpression "self.packages.\${pkgs.system}.default"; 271 + description = "The atBB package to use."; 272 + }; 273 + 274 + domain = lib.mkOption { 275 + type = lib.types.str; 276 + description = "Domain name for the forum (e.g., forum.example.com)."; 277 + }; 278 + 279 + enableNginx = lib.mkOption { 280 + type = lib.types.bool; 281 + default = true; 282 + description = "Whether to configure nginx as a reverse proxy."; 283 + }; 284 + 285 + enableACME = lib.mkOption { 286 + type = lib.types.bool; 287 + default = true; 288 + description = "Whether to enable ACME (Let's Encrypt) for TLS."; 289 + }; 290 + 291 + oauthPublicUrl = lib.mkOption { 292 + type = lib.types.str; 293 + default = "https://${cfg.domain}"; 294 + defaultText = lib.literalExpression ''"https://\${cfg.domain}"''; 295 + description = "Public URL for OAuth client metadata. Defaults to https://<domain>."; 296 + }; 297 + 298 + forumDid = lib.mkOption { 299 + type = lib.types.str; 300 + description = "The forum's AT Protocol DID."; 301 + }; 302 + 303 + pdsUrl = lib.mkOption { 304 + type = lib.types.str; 305 + description = "URL of the forum's PDS."; 306 + }; 307 + 308 + environmentFile = lib.mkOption { 309 + type = lib.types.path; 310 + description = '' 311 + Path to an environment file containing secrets. 312 + Must define: DATABASE_URL, SESSION_SECRET, FORUM_HANDLE, FORUM_PASSWORD. 313 + When database.enable = true, DATABASE_URL should be: 314 + postgres:///atbb?host=/run/postgresql (peer auth via Unix socket) 315 + ''; 316 + }; 317 + 318 + database = { 319 + enable = lib.mkOption { 320 + type = lib.types.bool; 321 + default = true; 322 + description = "Whether to configure a local PostgreSQL 17 instance."; 323 + }; 324 + 325 + name = lib.mkOption { 326 + type = lib.types.str; 327 + default = "atbb"; 328 + description = "Name of the PostgreSQL database."; 329 + }; 330 + }; 331 + 332 + appviewPort = lib.mkOption { 333 + type = lib.types.port; 334 + default = 3000; 335 + description = "Port for the appview API server (internal, behind nginx)."; 336 + }; 337 + 338 + webPort = lib.mkOption { 339 + type = lib.types.port; 340 + default = 3001; 341 + description = "Port for the web UI server (internal, behind nginx)."; 342 + }; 343 + 344 + seedDefaultRoles = lib.mkOption { 345 + type = lib.types.bool; 346 + default = true; 347 + description = "Whether to seed default roles on appview startup."; 348 + }; 349 + 350 + autoMigrate = lib.mkOption { 351 + type = lib.types.bool; 352 + default = false; 353 + description = '' 354 + Whether to automatically run database migrations before starting appview. 355 + When false, run migrations manually: systemctl start atbb-migrate 356 + ''; 357 + }; 358 + 359 + user = lib.mkOption { 360 + type = lib.types.str; 361 + default = "atbb"; 362 + description = "System user to run atBB services."; 363 + }; 364 + 365 + group = lib.mkOption { 366 + type = lib.types.str; 367 + default = "atbb"; 368 + description = "System group to run atBB services."; 369 + }; 370 + }; 371 + 372 + # config section will be added in Task 4 373 + config = lib.mkIf cfg.enable { }; 374 + } 375 + ``` 376 + 377 + **Step 2: Verify flake parses** 378 + 379 + ```bash 380 + nix flake check --no-build 381 + ``` 382 + 383 + Expected: No errors. 384 + 385 + **Step 3: Commit** 386 + 387 + ```bash 388 + git add nix/module.nix 389 + git commit -m "feat(nix): add NixOS module option declarations for services.atbb" 390 + ``` 391 + 392 + --- 393 + 394 + ### Task 4: Implement NixOS module — systemd services 395 + 396 + **Files:** 397 + - Modify: `nix/module.nix` (the `config` block) 398 + 399 + **Step 1: Add user/group, PostgreSQL, and systemd services to the config block** 400 + 401 + Replace the `config = lib.mkIf cfg.enable { };` line with: 402 + 403 + ```nix 404 + config = lib.mkIf cfg.enable { 405 + # ── System user ────────────────────────────────────────────── 406 + users.users.${cfg.user} = { 407 + isSystemUser = true; 408 + group = cfg.group; 409 + description = "atBB service user"; 410 + }; 411 + users.groups.${cfg.group} = { }; 412 + 413 + # ── PostgreSQL ─────────────────────────────────────────────── 414 + services.postgresql = lib.mkIf cfg.database.enable { 415 + enable = true; 416 + package = pkgs.postgresql_17; 417 + ensureDatabases = [ cfg.database.name ]; 418 + ensureUsers = [{ 419 + name = cfg.user; 420 + ensureDBOwnership = true; 421 + }]; 422 + }; 423 + 424 + # ── Database migration (oneshot) ───────────────────────────── 425 + systemd.services.atbb-migrate = { 426 + description = "atBB database migration"; 427 + after = [ "network.target" ] 428 + ++ lib.optional cfg.database.enable "postgresql.service"; 429 + requires = lib.optional cfg.database.enable "postgresql.service"; 430 + 431 + serviceConfig = { 432 + Type = "oneshot"; 433 + User = cfg.user; 434 + Group = cfg.group; 435 + WorkingDirectory = "${cfg.package}/apps/appview"; 436 + ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate"; 437 + EnvironmentFile = cfg.environmentFile; 438 + RemainAfterExit = true; 439 + 440 + # Hardening 441 + NoNewPrivileges = true; 442 + ProtectSystem = "strict"; 443 + ProtectHome = true; 444 + PrivateTmp = true; 445 + PrivateDevices = true; 446 + }; 447 + }; 448 + 449 + # ── AppView API server ─────────────────────────────────────── 450 + systemd.services.atbb-appview = { 451 + description = "atBB AppView API server"; 452 + after = [ "network.target" ] 453 + ++ lib.optional cfg.database.enable "postgresql.service" 454 + ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; 455 + requires = lib.optionals cfg.database.enable [ "postgresql.service" ] 456 + ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; 457 + wantedBy = [ "multi-user.target" ]; 458 + 459 + environment = { 460 + NODE_ENV = "production"; 461 + PORT = toString cfg.appviewPort; 462 + FORUM_DID = cfg.forumDid; 463 + PDS_URL = cfg.pdsUrl; 464 + OAUTH_PUBLIC_URL = cfg.oauthPublicUrl; 465 + SEED_DEFAULT_ROLES = lib.boolToString cfg.seedDefaultRoles; 466 + }; 467 + 468 + serviceConfig = { 469 + Type = "simple"; 470 + User = cfg.user; 471 + Group = cfg.group; 472 + WorkingDirectory = "${cfg.package}/apps/appview"; 473 + ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/appview/dist/index.js"; 474 + EnvironmentFile = cfg.environmentFile; 475 + Restart = "on-failure"; 476 + RestartSec = 5; 477 + 478 + # Hardening 479 + NoNewPrivileges = true; 480 + ProtectSystem = "strict"; 481 + ProtectHome = true; 482 + PrivateTmp = true; 483 + PrivateDevices = true; 484 + ProtectKernelTunables = true; 485 + ProtectKernelModules = true; 486 + ProtectControlGroups = true; 487 + RestrictSUIDSGID = true; 488 + }; 489 + }; 490 + 491 + # ── Web UI server ──────────────────────────────────────────── 492 + systemd.services.atbb-web = { 493 + description = "atBB Web UI server"; 494 + after = [ "atbb-appview.service" ]; 495 + wantedBy = [ "multi-user.target" ]; 496 + 497 + environment = { 498 + NODE_ENV = "production"; 499 + WEB_PORT = toString cfg.webPort; 500 + APPVIEW_URL = "http://localhost:${toString cfg.appviewPort}"; 501 + }; 502 + 503 + serviceConfig = { 504 + Type = "simple"; 505 + User = cfg.user; 506 + Group = cfg.group; 507 + WorkingDirectory = "${cfg.package}/apps/web"; 508 + ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/web/dist/index.js"; 509 + Restart = "on-failure"; 510 + RestartSec = 5; 511 + 512 + # Hardening 513 + NoNewPrivileges = true; 514 + ProtectSystem = "strict"; 515 + ProtectHome = true; 516 + PrivateTmp = true; 517 + PrivateDevices = true; 518 + ProtectKernelTunables = true; 519 + ProtectKernelModules = true; 520 + ProtectControlGroups = true; 521 + RestrictSUIDSGID = true; 522 + }; 523 + }; 524 + }; 525 + ``` 526 + 527 + **Step 2: Verify flake parses** 528 + 529 + ```bash 530 + nix flake check --no-build 531 + ``` 532 + 533 + Expected: No errors. 534 + 535 + **Step 3: Commit** 536 + 537 + ```bash 538 + git add nix/module.nix 539 + git commit -m "feat(nix): add systemd services and PostgreSQL to NixOS module" 540 + ``` 541 + 542 + --- 543 + 544 + ### Task 5: Implement NixOS module — nginx virtualHost 545 + 546 + **Files:** 547 + - Modify: `nix/module.nix` (add nginx config to the `config` block) 548 + 549 + **Step 1: Add nginx configuration inside the `config = lib.mkIf cfg.enable { ... }` block** 550 + 551 + Add these blocks after the `systemd.services.atbb-web` block, still inside the `config = lib.mkIf cfg.enable { ... }`: 552 + 553 + ```nix 554 + # ── Nginx reverse proxy ────────────────────────────────────── 555 + services.nginx = lib.mkIf cfg.enableNginx { 556 + enable = true; 557 + recommendedProxySettings = true; 558 + recommendedTlsSettings = true; 559 + recommendedOptimisation = true; 560 + 561 + virtualHosts.${cfg.domain} = { 562 + forceSSL = cfg.enableACME; 563 + enableACME = cfg.enableACME; 564 + 565 + locations."/.well-known/" = { 566 + proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; 567 + recommendedProxySettings = true; 568 + }; 569 + 570 + locations."/api/" = { 571 + proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; 572 + recommendedProxySettings = true; 573 + }; 574 + 575 + locations."/" = { 576 + proxyPass = "http://127.0.0.1:${toString cfg.webPort}"; 577 + recommendedProxySettings = true; 578 + }; 579 + }; 580 + }; 581 + ``` 582 + 583 + **Step 2: Verify flake parses** 584 + 585 + ```bash 586 + nix flake check --no-build 587 + ``` 588 + 589 + Expected: No errors. 590 + 591 + **Step 3: Commit** 592 + 593 + ```bash 594 + git add nix/module.nix 595 + git commit -m "feat(nix): add nginx virtualHost with ACME to NixOS module" 596 + ``` 597 + 598 + --- 599 + 600 + ### Task 6: Add .gitignore entry for result symlink and final verification 601 + 602 + **Files:** 603 + - Modify: `.gitignore` 604 + 605 + **Step 1: Add `result` to .gitignore** 606 + 607 + `nix build` creates a `result` symlink in the project root. Add it to `.gitignore`: 608 + 609 + ``` 610 + # Nix build output 611 + result 612 + ``` 613 + 614 + **Step 2: Run full flake check** 615 + 616 + ```bash 617 + nix flake check --no-build 618 + ``` 619 + 620 + Expected: No errors. 621 + 622 + **Step 3: Verify the build still succeeds** 623 + 624 + ```bash 625 + nix build 626 + ``` 627 + 628 + Expected: Build succeeds. The `result/` symlink points to the built atBB package. 629 + 630 + **Step 4: Verify runtime artifacts** 631 + 632 + ```bash 633 + # Check appview dist 634 + ls result/apps/appview/dist/index.js 635 + 636 + # Check web dist 637 + ls result/apps/web/dist/index.js 638 + 639 + # Check drizzle migrations 640 + ls result/apps/appview/drizzle/ 641 + 642 + # Check static assets 643 + ls result/apps/web/public/static/ 644 + 645 + # Check node_modules resolve 646 + ls result/node_modules/.pnpm/ 647 + ``` 648 + 649 + All paths should exist. 650 + 651 + **Step 5: Commit** 652 + 653 + ```bash 654 + git add .gitignore 655 + git commit -m "chore: add Nix result symlink to .gitignore" 656 + ``` 657 + 658 + --- 659 + 660 + ## Troubleshooting Reference 661 + 662 + ### pnpm.fetchDeps hash mismatch 663 + If `pnpm-lock.yaml` changes (new dependency, lockfile update), the `hash` in `nix/package.nix` must be updated. Set to `lib.fakeHash`, run `nix build`, copy the new hash. 664 + 665 + ### configHook install failures 666 + `pnpm_9.configHook` runs `pnpm install --offline --frozen-lockfile --ignore-script` in `configurePhase`. If a package needs postinstall scripts with native binaries, you may need to add `preBuild` hooks to patch them. 667 + 668 + ### Lexicon build needs bash glob expansion 669 + The lexicon build script uses `bash -c 'shopt -s globstar && ...'`. The `bash` in `nativeBuildInputs` provides this. If the build fails with glob errors, verify bash is listed. 670 + 671 + ### drizzle-kit migrate needs TypeScript 672 + `drizzle.config.ts` is a TypeScript file. drizzle-kit includes its own TS transpiler, so `tsx` is not required at runtime. The config file references `../../packages/db/src/schema.ts` — this path resolves relative to `WorkingDirectory` in the systemd service. 673 + 674 + ### NixOS module consumption 675 + The consumer must set `security.acme.acceptTerms = true` and `security.acme.defaults.email` when using `enableACME = true`. The module does not force these values.
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1771207753, 6 + "narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "d1c15b7d5806069da59e819999d70e1cec0760bf", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixpkgs-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+23
flake.nix
··· 1 + { 2 + description = "atBB — decentralized BB-style forum on AT Protocol"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 + }; 7 + 8 + outputs = { self, nixpkgs }: 9 + let 10 + supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; 11 + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 12 + in 13 + { 14 + packages = forAllSystems (system: 15 + let pkgs = nixpkgs.legacyPackages.${system}; 16 + in { 17 + default = pkgs.callPackage ./nix/package.nix { }; 18 + } 19 + ); 20 + 21 + nixosModules.default = import ./nix/module.nix self; 22 + }; 23 + }
+82
nix/Caddyfile.example
··· 1 + # atBB — Example Caddyfile for NixOS deployments 2 + # 3 + # Use this if you prefer Caddy over the built-in nginx reverse proxy. 4 + # 5 + # In your NixOS configuration, set: 6 + # 7 + # services.atbb.enableNginx = false; 8 + # 9 + # Then configure Caddy to take over the routing, either via a raw Caddyfile: 10 + # 11 + # services.caddy.configFile = ./Caddyfile; 12 + # 13 + # Or inline using the virtualHosts option (see the NixOS snippet below). 14 + # 15 + # Caddy will automatically obtain and renew a Let's Encrypt TLS certificate 16 + # for your domain — no ACME configuration needed beyond a valid DNS record 17 + # pointing to the server. 18 + 19 + # ── Routing overview ────────────────────────────────────────────────────────── 20 + # 21 + # /.well-known/* → appview (default port 3000) 22 + # /api/* → appview (default port 3000) 23 + # /* → web UI (default port 3001) 24 + # 25 + # IMPORTANT: /.well-known/ MUST be routed to appview, not the web UI. 26 + # The AT Protocol OAuth flow fetches {client_id}/.well-known/oauth-client-metadata 27 + # from your forum's domain to validate the OAuth client. If this request reaches 28 + # the web UI instead of appview, login will silently fail. 29 + # 30 + # ───────────────────────────────────────────────────────────────────────────── 31 + 32 + forum.example.com { 33 + 34 + # AT Protocol well-known endpoints → appview 35 + # Handles OAuth client metadata and any future AT Proto service discovery 36 + handle /.well-known/* { 37 + reverse_proxy localhost:3000 38 + } 39 + 40 + # REST API → appview 41 + handle /api/* { 42 + reverse_proxy localhost:3000 43 + } 44 + 45 + # Web UI — catch-all (must come last) 46 + handle { 47 + reverse_proxy localhost:3001 48 + } 49 + 50 + } 51 + 52 + # ── NixOS integration snippet ───────────────────────────────────────────────── 53 + # 54 + # Equivalent configuration using services.caddy.virtualHosts in NixOS: 55 + # 56 + # services.atbb = { 57 + # enable = true; 58 + # domain = "forum.example.com"; 59 + # enableNginx = false; # disable the built-in nginx virtualHost 60 + # # ... other options 61 + # }; 62 + # 63 + # services.caddy = { 64 + # enable = true; 65 + # virtualHosts."forum.example.com".extraConfig = '' 66 + # handle /.well-known/* { 67 + # reverse_proxy localhost:${toString config.services.atbb.appviewPort} 68 + # } 69 + # 70 + # handle /api/* { 71 + # reverse_proxy localhost:${toString config.services.atbb.appviewPort} 72 + # } 73 + # 74 + # handle { 75 + # reverse_proxy localhost:${toString config.services.atbb.webPort} 76 + # } 77 + # ''; 78 + # }; 79 + # 80 + # Caddy automatically provisions and renews a Let's Encrypt certificate for 81 + # the virtualHost — no security.acme configuration required. 82 + # ─────────────────────────────────────────────────────────────────────────────
+315
nix/module.nix
··· 1 + self: 2 + 3 + { config, lib, pkgs, ... }: 4 + 5 + let 6 + cfg = config.services.atbb; 7 + nodejs = pkgs.nodejs_22; 8 + in 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 + enable = lib.mkOption { 66 + type = lib.types.bool; 67 + default = true; 68 + description = "Whether to configure a local PostgreSQL 17 instance."; 69 + }; 70 + 71 + name = lib.mkOption { 72 + type = lib.types.str; 73 + default = "atbb"; 74 + description = "Name of the PostgreSQL database."; 75 + }; 76 + }; 77 + 78 + appviewPort = lib.mkOption { 79 + type = lib.types.port; 80 + default = 3000; 81 + description = "Port for the appview API server (internal, behind nginx)."; 82 + }; 83 + 84 + webPort = lib.mkOption { 85 + type = lib.types.port; 86 + default = 3001; 87 + description = "Port for the web UI server (internal, behind nginx)."; 88 + }; 89 + 90 + seedDefaultRoles = lib.mkOption { 91 + type = lib.types.bool; 92 + default = true; 93 + description = "Whether to seed default roles on appview startup."; 94 + }; 95 + 96 + autoMigrate = lib.mkOption { 97 + type = lib.types.bool; 98 + default = false; 99 + description = '' 100 + Whether to automatically run database migrations before starting appview. 101 + When false, run migrations manually: systemctl start atbb-migrate 102 + ''; 103 + }; 104 + 105 + user = lib.mkOption { 106 + type = lib.types.str; 107 + default = "atbb"; 108 + description = "System user to run atBB services."; 109 + }; 110 + 111 + group = lib.mkOption { 112 + type = lib.types.str; 113 + default = "atbb"; 114 + description = "System group to run atBB services."; 115 + }; 116 + }; 117 + 118 + config = lib.mkIf cfg.enable { 119 + # ── Assertions ─────────────────────────────────────────────── 120 + assertions = [ 121 + { 122 + assertion = !cfg.database.enable || cfg.user == cfg.database.name; 123 + message = '' 124 + services.atbb: When database.enable is true, the user name must match 125 + the database name for ensureDBOwnership to work. Current values: 126 + user = "${cfg.user}", database.name = "${cfg.database.name}". 127 + Set both to the same value, or use database.enable = false and manage 128 + PostgreSQL manually. 129 + ''; 130 + } 131 + { 132 + assertion = !cfg.enableACME 133 + || (config.security.acme.acceptTerms 134 + && config.security.acme.defaults.email != ""); 135 + message = '' 136 + services.atbb: enableACME requires security.acme.acceptTerms = true 137 + and security.acme.defaults.email to be set. Example: 138 + security.acme.acceptTerms = true; 139 + security.acme.defaults.email = "admin@example.com"; 140 + ''; 141 + } 142 + ]; 143 + 144 + # ── CLI on system PATH ─────────────────────────────────────── 145 + # Makes `atbb` available to all users so administrators can run 146 + # setup and management commands (atbb init, atbb category add, etc.) 147 + environment.systemPackages = [ cfg.package ]; 148 + 149 + # ── System user ────────────────────────────────────────────── 150 + users.users.${cfg.user} = { 151 + isSystemUser = true; 152 + group = cfg.group; 153 + description = "atBB service user"; 154 + }; 155 + users.groups.${cfg.group} = { }; 156 + 157 + # ── PostgreSQL ─────────────────────────────────────────────── 158 + services.postgresql = lib.mkIf cfg.database.enable { 159 + enable = true; 160 + package = pkgs.postgresql_17; 161 + ensureDatabases = [ cfg.database.name ]; 162 + ensureUsers = [{ 163 + name = cfg.user; 164 + ensureDBOwnership = true; 165 + }]; 166 + }; 167 + 168 + # ── Database migration (oneshot) ───────────────────────────── 169 + systemd.services.atbb-migrate = { 170 + description = "atBB database migration"; 171 + after = [ "network.target" ] 172 + ++ lib.optional cfg.database.enable "postgresql.service"; 173 + requires = lib.optional cfg.database.enable "postgresql.service"; 174 + 175 + # pnpm .bin/ shims are shell scripts that call `node` by name in their 176 + # body. patchShebangs only patches the shebang line, leaving the body's 177 + # `node` invocation as a PATH lookup. The `path` option prepends 178 + # packages to the service PATH without conflicting with NixOS defaults. 179 + path = [ nodejs ]; 180 + 181 + environment = lib.optionalAttrs cfg.database.enable { 182 + # PGHOST tells postgres.js / drizzle-kit to use the Unix socket 183 + # directory rather than relying on ?host= URL query param parsing. 184 + PGHOST = "/run/postgresql"; 185 + }; 186 + 187 + serviceConfig = { 188 + Type = "oneshot"; 189 + User = cfg.user; 190 + Group = cfg.group; 191 + WorkingDirectory = "${cfg.package}/apps/appview"; 192 + ExecStart = "${cfg.package}/apps/appview/node_modules/.bin/drizzle-kit migrate"; 193 + EnvironmentFile = cfg.environmentFile; 194 + RemainAfterExit = true; 195 + 196 + # Hardening 197 + NoNewPrivileges = true; 198 + ProtectSystem = "strict"; 199 + ProtectHome = true; 200 + PrivateTmp = true; 201 + PrivateDevices = true; 202 + ProtectKernelTunables = true; 203 + ProtectKernelModules = true; 204 + ProtectControlGroups = true; 205 + RestrictSUIDSGID = true; 206 + }; 207 + }; 208 + 209 + # ── AppView API server ─────────────────────────────────────── 210 + systemd.services.atbb-appview = { 211 + description = "atBB AppView API server"; 212 + after = [ "network.target" ] 213 + ++ lib.optional cfg.database.enable "postgresql.service" 214 + ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; 215 + requires = lib.optionals cfg.database.enable [ "postgresql.service" ] 216 + ++ lib.optional cfg.autoMigrate "atbb-migrate.service"; 217 + wantedBy = [ "multi-user.target" ]; 218 + 219 + environment = { 220 + NODE_ENV = "production"; 221 + PORT = toString cfg.appviewPort; 222 + FORUM_DID = cfg.forumDid; 223 + PDS_URL = cfg.pdsUrl; 224 + OAUTH_PUBLIC_URL = cfg.oauthPublicUrl; 225 + SEED_DEFAULT_ROLES = lib.boolToString cfg.seedDefaultRoles; 226 + } // lib.optionalAttrs cfg.database.enable { 227 + # Explicit socket directory so postgres.js uses Unix peer auth 228 + # regardless of how it parses the DATABASE_URL host parameter. 229 + PGHOST = "/run/postgresql"; 230 + }; 231 + 232 + serviceConfig = { 233 + Type = "simple"; 234 + User = cfg.user; 235 + Group = cfg.group; 236 + WorkingDirectory = "${cfg.package}/apps/appview"; 237 + ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/appview/dist/index.js"; 238 + EnvironmentFile = cfg.environmentFile; 239 + Restart = "on-failure"; 240 + RestartSec = 5; 241 + 242 + # Hardening 243 + NoNewPrivileges = true; 244 + ProtectSystem = "strict"; 245 + ProtectHome = true; 246 + PrivateTmp = true; 247 + PrivateDevices = true; 248 + ProtectKernelTunables = true; 249 + ProtectKernelModules = true; 250 + ProtectControlGroups = true; 251 + RestrictSUIDSGID = true; 252 + }; 253 + }; 254 + 255 + # ── Web UI server ──────────────────────────────────────────── 256 + systemd.services.atbb-web = { 257 + description = "atBB Web UI server"; 258 + after = [ "network.target" "atbb-appview.service" ]; 259 + requires = [ "atbb-appview.service" ]; 260 + wantedBy = [ "multi-user.target" ]; 261 + 262 + environment = { 263 + NODE_ENV = "production"; 264 + WEB_PORT = toString cfg.webPort; 265 + APPVIEW_URL = "http://localhost:${toString cfg.appviewPort}"; 266 + }; 267 + 268 + serviceConfig = { 269 + Type = "simple"; 270 + User = cfg.user; 271 + Group = cfg.group; 272 + WorkingDirectory = "${cfg.package}/apps/web"; 273 + ExecStart = "${nodejs}/bin/node ${cfg.package}/apps/web/dist/index.js"; 274 + Restart = "on-failure"; 275 + RestartSec = 5; 276 + 277 + # Hardening 278 + NoNewPrivileges = true; 279 + ProtectSystem = "strict"; 280 + ProtectHome = true; 281 + PrivateTmp = true; 282 + PrivateDevices = true; 283 + ProtectKernelTunables = true; 284 + ProtectKernelModules = true; 285 + ProtectControlGroups = true; 286 + RestrictSUIDSGID = true; 287 + }; 288 + }; 289 + 290 + # ── Nginx reverse proxy ────────────────────────────────────── 291 + services.nginx = lib.mkIf cfg.enableNginx { 292 + enable = true; 293 + recommendedProxySettings = true; 294 + recommendedTlsSettings = true; 295 + recommendedOptimisation = true; 296 + 297 + virtualHosts.${cfg.domain} = { 298 + forceSSL = cfg.enableACME; 299 + enableACME = cfg.enableACME; 300 + 301 + locations."/.well-known/" = { 302 + proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; 303 + }; 304 + 305 + locations."/api/" = { 306 + proxyPass = "http://127.0.0.1:${toString cfg.appviewPort}"; 307 + }; 308 + 309 + locations."/" = { 310 + proxyPass = "http://127.0.0.1:${toString cfg.webPort}"; 311 + }; 312 + }; 313 + }; 314 + }; 315 + }
+90
nix/package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + nodejs_22, 5 + pnpm_9, 6 + bash, 7 + makeWrapper, 8 + }: 9 + 10 + stdenv.mkDerivation (finalAttrs: { 11 + pname = "atbb"; 12 + version = "0.1.0"; 13 + 14 + src = lib.fileset.toSource { 15 + root = ../.; 16 + fileset = lib.fileset.unions [ 17 + ../package.json 18 + ../pnpm-lock.yaml 19 + ../pnpm-workspace.yaml 20 + ../turbo.json 21 + ../tsconfig.base.json 22 + ../apps 23 + ../packages 24 + ]; 25 + }; 26 + 27 + pnpmDeps = pnpm_9.fetchDeps { 28 + inherit (finalAttrs) pname version src; 29 + fetcherVersion = 1; 30 + hash = "sha256-r0RxK86TP9RqpHozJDaL6cEokHSKee8bPxFCLX20GAE="; 31 + }; 32 + 33 + nativeBuildInputs = [ 34 + nodejs_22 35 + pnpm_9.configHook 36 + bash 37 + makeWrapper 38 + ]; 39 + 40 + buildPhase = '' 41 + runHook preBuild 42 + pnpm build 43 + runHook postBuild 44 + ''; 45 + 46 + installPhase = '' 47 + runHook preInstall 48 + 49 + mkdir -p $out 50 + 51 + # Workspace config 52 + cp package.json pnpm-lock.yaml pnpm-workspace.yaml $out/ 53 + 54 + # Root node_modules (pnpm virtual store — symlinks point into Nix store FOD) 55 + cp -r node_modules $out/ 56 + 57 + # Each workspace package: package.json + dist/ + local node_modules symlinks 58 + for pkg in apps/appview apps/web packages/db packages/atproto packages/cli packages/lexicon; do 59 + mkdir -p "$out/$pkg" 60 + cp "$pkg/package.json" "$out/$pkg/" 61 + [ -d "$pkg/dist" ] && cp -r "$pkg/dist" "$out/$pkg/" 62 + [ -d "$pkg/node_modules" ] && cp -r "$pkg/node_modules" "$out/$pkg/" 63 + done 64 + 65 + # DB schema source (needed by drizzle.config.ts which references ../../packages/db/src/schema.ts) 66 + cp -r packages/db/src $out/packages/db/ 67 + 68 + # Drizzle migrations (needed for db:migrate at deploy time) 69 + cp -r apps/appview/drizzle $out/apps/appview/ 70 + 71 + # drizzle.config.ts (needed by drizzle-kit migrate) 72 + cp apps/appview/drizzle.config.ts $out/apps/appview/ 73 + 74 + # Web static assets (CSS, favicon — served by hono serveStatic) 75 + cp -r apps/web/public $out/apps/web/ 76 + 77 + # CLI wrapper — makes `atbb` available on PATH 78 + mkdir -p $out/bin 79 + makeWrapper ${nodejs_22}/bin/node $out/bin/atbb \ 80 + --add-flags "$out/packages/cli/dist/index.js" 81 + 82 + runHook postInstall 83 + ''; 84 + 85 + meta = with lib; { 86 + description = "atBB — decentralized BB-style forum on AT Protocol"; 87 + license = licenses.agpl3Only; 88 + platforms = [ "x86_64-linux" "aarch64-linux" ]; 89 + }; 90 + })