webapp-module/webapp.nix
Padreug 8d3c43b160 feat: synthesize brand kit from appName when brandDir unset
Adds back `services.webapp.appName` (briefly removed in the previous
commit per #100's strict policy) so existing hosts keep working
without authoring full per-host brand kits.

Resolution order:
1. If brandDir is set, use it (full brand kit wins).
2. Else synthesize: take the flake's default logo + a brand.json
   carrying appName as both name and shortName.

This is NOT a VITE_APP_NAME env shim — it threads through the brand
kit pipeline properly (brand.json read by vite-branding.ts). Hosts
that need themed chrome / their own logo upgrade to a real brandDir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 17:13:05 +02:00

306 lines
10 KiB
Nix

# NixOS module for serving the Vue 3 webapp.
#
# Build is delegated to the webapp flake's lib.mkWebapp (since
# aiolabs/webapp#97). This module owns:
# - the nginx vhost
# - the per-deployment VITE_* env vars (relays, LNbits URL, hub
# tile URLs, …) that get baked into the bundle
# - brand kit selection via `brandDir`
#
# Per-deployment name customization flows through brand.json under
# `brandDir`, NOT through a `VITE_APP_NAME` knob — see strict policy
# landed in aiolabs/webapp#99 / #100.
{ config, lib, pkgs, ... }:
let
cfg = config.services.webapp;
# Synthesize a brand kit from `appName` when no explicit brandDir is
# provided: take the flake's default logo and overlay a brand.json
# carrying the host's app name. This lets existing hosts keep
# `services.webapp.appName = "Bouge"` and get "Bouge" in the manifest
# WITHOUT needing to author a full brand kit per host. Hosts that DO
# author a brand kit (logo, theme colors) supply `brandDir` directly
# and `appName` becomes informational.
synthesizedBrand = pkgs.runCommand "aio-webapp-brand-${cfg.appName}" {} ''
mkdir -p $out
cp ${cfg.flake}/branding/default/logo.png $out/logo.png
cat > $out/brand.json <<EOF
{"name":${builtins.toJSON cfg.appName},"shortName":${builtins.toJSON cfg.appName}}
EOF
'';
resolvedBrandDir =
if cfg.brandDir != null then cfg.brandDir
else synthesizedBrand;
webapp = cfg.flake.lib.mkWebapp {
inherit pkgs;
brandDir = resolvedBrandDir;
extraEnv = {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
NODE_ENV = "production";
VITE_NOSTR_RELAYS = builtins.toJSON cfg.nostrRelays;
VITE_ADMIN_PUBKEYS = builtins.toJSON cfg.adminPubkeys;
VITE_LNBITS_BASE_URL = cfg.lnbitsBaseUrl;
VITE_LNBITS_DEBUG = if cfg.lnbitsDebug then "true" else "false";
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY = cfg.lnbitsNostrTransportPubkey;
VITE_WEBSOCKET_ENABLED = if cfg.websocketEnabled then "true" else "false";
VITE_LIGHTNING_DOMAIN = cfg.lightningDomain;
VITE_VAPID_PUBLIC_KEY = cfg.vapidPublicKey;
VITE_PUSH_NOTIFICATIONS_ENABLED = if cfg.pushNotificationsEnabled then "true" else "false";
VITE_PICTRS_BASE_URL = cfg.pictrsBaseUrl;
VITE_MARKET_NADDR = cfg.marketNaddr;
VITE_DEMO_MODE = if cfg.demoMode then "true" else "false";
VITE_HUB_EVENTS_URL = cfg.hubEventsUrl;
VITE_HUB_LIBRA_URL = cfg.hubLibraUrl;
VITE_HUB_WALLET_URL = cfg.hubWalletUrl;
VITE_HUB_CHAT_URL = cfg.hubChatUrl;
VITE_HUB_FORUM_URL = cfg.hubForumUrl;
VITE_HUB_MARKET_URL = cfg.hubMarketUrl;
VITE_HUB_TASKS_URL = cfg.hubTasksUrl;
VITE_HUB_RESTAURANT_URL = cfg.hubRestaurantUrl;
};
};
in {
options.services.webapp = {
enable = lib.mkEnableOption "AIO webapp";
flake = lib.mkOption {
type = lib.types.attrs;
example = lib.literalExpression "inputs.webapp";
description = ''
The webapp flake (as a flake input, not raw source). The module
calls `flake.lib.mkWebapp` to build. Pin per-host to pick the
right channel `inputs.webapp` for main, `inputs.webapp-dev`
for the dev/staging channel.
'';
};
brandDir = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = lib.literalExpression "./../branding/cfaun";
description = ''
Path to the brand kit directory consumed by the build. When
null (default), the module synthesizes a kit from `appName` +
the flake's default logo keeps existing hosts working
unchanged. Set this to a real brand kit when the host wants its
own logo + manifest theme. See `branding/README.md` in the
webapp repo.
'';
};
appName = lib.mkOption {
type = lib.types.str;
default = "AIO";
example = "Bouge";
description = ''
Application name shown in the PWA manifest, browser tab, and
home-screen install label. Used as the `brand.name` of a
synthesized brand kit when `brandDir` is null. When `brandDir`
is set, this option becomes informational only brand.json
wins.
'';
};
domain = lib.mkOption {
type = lib.types.str;
example = "app.example.com";
description = "Domain name for the webapp";
};
# Nostr Configuration
nostrRelays = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "wss://relay.damus.io" "wss://relay.snort.social" ];
description = "List of Nostr relay URLs";
};
adminPubkeys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "List of admin Nostr public keys (hex format)";
};
# API Configuration
lnbitsBaseUrl = lib.mkOption {
type = lib.types.str;
example = "https://lnbits.example.com";
description = "LNBits API base URL";
};
lnbitsDebug = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable LNBits debug mode";
};
lnbitsNostrTransportPubkey = lib.mkOption {
type = lib.types.str;
default = "";
example = "ac766168d4f3232772b16002a059cdf2ffed7293a25da620a2ddbdab7691c07f";
description = ''
Hex-encoded Nostr public key identifying the LNbits instance on
the relay network. The webapp uses this to address NIP-44 v2
encrypted kind-21000 RPC requests at the lnbits backend over
Nostr instead of HTTP. Empty string = nostr transport disabled.
'';
};
websocketEnabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable WebSocket for real-time updates";
};
# Lightning Address Domain
lightningDomain = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
Domain used for Lightning Addresses.
Example: mydomain.com will show addresses as username@mydomain.com
'';
};
# Push Notifications
vapidPublicKey = lib.mkOption {
type = lib.types.str;
default = "";
description = "VAPID public key for push notifications";
};
pushNotificationsEnabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable push notifications";
};
# Image Upload Configuration (pict-rs)
pictrsBaseUrl = lib.mkOption {
type = lib.types.str;
default = "";
description = "Pict-rs image service base URL";
};
# Market Configuration
marketNaddr = lib.mkOption {
type = lib.types.str;
default = "";
description = "Nostr address (naddr) for the market configuration";
};
demoMode = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable demo mode with auto-generated demo accounts on the login page";
};
# Hub → standalone navigation URLs (one per chakra tile).
# Empty string = tile rendered ghosted (no link). Set per-host based on
# whether each standalone is deployed and on the deployment shape
# (path-mode → trailing slash recommended; subdomain-mode → no path).
hubEventsUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/events/";
description = "URL the chakra hub's Events tile links to";
};
hubLibraUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/libra/";
description = "URL the chakra hub's Libra tile links to";
};
hubWalletUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/wallet/";
description = "URL the chakra hub's Wallet tile links to";
};
hubChatUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/chat/";
description = "URL the chakra hub's Chat tile links to";
};
hubForumUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/forum/";
description = "URL the chakra hub's Forum tile links to";
};
hubMarketUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/market/";
description = "URL the chakra hub's Market tile links to";
};
hubTasksUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/tasks/";
description = "URL the chakra hub's Tasks tile links to";
};
hubRestaurantUrl = lib.mkOption {
type = lib.types.str;
default = "";
example = "https://demo.example.com/restaurant/";
description = "URL the chakra hub's Restaurant tile links to";
};
enableSSL = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable SSL/TLS with Let's Encrypt";
};
acmeEmail = lib.mkOption {
type = lib.types.str;
default = "";
description = "Email for Let's Encrypt certificate notifications";
};
};
config = lib.mkIf cfg.enable {
# lib.mkWebapp's output layout is `$out/dist/`, not `$out/share/webapp/`,
# so the vhost root points directly at dist/.
services.nginx.virtualHosts.${cfg.domain} = {
forceSSL = cfg.enableSSL;
enableACME = cfg.enableSSL;
root = "${webapp}/dist";
locations = {
"/" = {
# SPA shell must revalidate on every load so new deploys are
# picked up without the browser holding a stale shell pointing
# at deleted hashed assets. The more-specific regex location
# below overrides this for hashed static assets, which stay
# immutable.
tryFiles = "$uri $uri/ /index.html";
extraConfig = ''
add_header Cache-Control "no-cache";
'';
};
# Cache static assets aggressively (Vite emits content-hashed filenames)
"~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$" = {
extraConfig = ''
expires 1y;
add_header Cache-Control "public, immutable";
'';
};
};
};
# ACME and firewall are configured globally in config/nginx.nix
};
}