{ config, pkgs, inputs, ... }: # livesync-bridge (vrtmrz) — mirrors an Obsidian LiveSync vault out of CouchDB # on jupiter (services/dev/obsidian-livesync.nix) into a real directory of # markdown here, so luna can read and write the vault as files. Obsidian itself # is an Electron GUI with no headless mode, and an agent wants files anyway. # # ⚠️ THE WRITE-BACK PATH IS THE RISKY ONE. Upstream has three open, unanswered # issues on storage->couchdb — #50 (Jun 2026, writes detected and logged as # uploaded, database never updated), #23 (only lowercase filenames transmitted # from storage), #46 (silent stall on files over ~30KB). All fail QUIETLY: the # log says success and the note never arrives. So do not treat this directory # as durable storage for anything luna cannot regenerate, and check that her # edits actually reach your devices before trusting it. (E2EE itself is fine — # PeerCouchDB.ts hard-errors if a passphrase is missing for an encrypted # remote, so it is a deliberate code path. The one issue claiming E2EE breaks # bridging, #12, is a single unreproduced report with no maintainer reply.) # # # EXPECTED NOISE ON FIRST SYNC: a stack trace per historically-deleted file — # NotFound: ... remove '/Welcome.md' at PeerStorage.delete # CouchDB keeps deletion tombstones, and the bridge replays them against a # directory where the file never existed. PeerStorage.ts:33-40 catches it, # logs, and returns false, so nothing is wrong; it only LOOKS fatal because # main.ts pins the logger to LOG_LEVEL_DEBUG, which prints exception dumps # that are otherwise verbose-level. It stops once the initial catch-up ends. # Talks to CouchDB over the TAILNET (jupiter.orbit.sol:5984), not through # neptun: mars is a tailnet node, so the public vhost, its TLS and its path # allowlist are all irrelevant here. let stateDir = "/var/lib/livesync-bridge"; appDir = "${stateDir}/app"; vaultDir = "${stateDir}/vault"; # The same uid/gid the hermes-agent container runs as (hermes-agent.nix). # Deliberate: the bridge and luna both read and write these files, and # sharing one uid removes any dependence on the container's umask. Two # different uids in a shared group only works while every file stays # group-writable, and a single 0644 file dropped by the agent would stall # sync on that path with nothing but a permission error in the log. hermesUid = 986; # Which vault. `group` is what pairs the two peers — both must match or the # bridge starts cleanly and simply never syncs anything. # # ⚠️ `database` must be the name entered in the Obsidian plugin for luna's # vault. Get it wrong and nothing errors: the credential below is CouchDB's # admin, so PouchDB CREATES the misnamed database and replicates an empty # vault into it quite happily. peerGroup = "luna"; database = "luna_wiki"; in { # hermes-agent.nix declares the GROUP (gid 983) but no user: the container # brings its own uid and needs no host account. The bridge does need one to # run as, so the matching user is declared here. users.users.hermes = { uid = hermesUid; group = "hermes"; isSystemUser = true; home = stateDir; description = "Hermes agent uid, shared with the livesync-bridge service"; }; # Created here rather than by the service so they exist before anything # tries to use them: # - vaultDir before podman-hermes-agent starts, because a bind-mount # source that does not exist is created by podman as root:root and the # bridge then cannot write into its own vault; # - appDir because WorkingDirectory applies to ExecStartPre as well, so a # missing one fails the unit before preStart ever gets to create it. systemd.tmpfiles.rules = [ "d ${vaultDir} 0770 hermes hermes -" "d ${appDir} 0750 hermes hermes -" "d ${stateDir}/deno 0750 hermes hermes -" ]; # The bridge's peer config, rendered by sops because it carries three # secrets inline (CouchDB password + both passphrases) and the file format # has no include mechanism. # # ⚠️ sops substitutes placeholders into the ALREADY-RENDERED json, so a # secret containing a double quote or a backslash produces an invalid config # and the bridge logs "Could not parse configuration!" and then sits there # with zero peers — it does not exit. Keep all three values alphanumeric. sops.templates."livesync-bridge.json" = { owner = "hermes"; content = builtins.toJSON { peers = [ { type = "couchdb"; name = "luna-remote"; group = peerGroup; url = "http://jupiter.orbit.sol:5984"; inherit database; username = "obsidian"; password = config.sops.placeholder.couchdb_luna_password; passphrase = config.sops.placeholder.obsidian_luna_passphrase; # The plugin derives path obfuscation from the same passphrase it # uses for content, so this is the same secret. Split into its own # field because the bridge takes them separately — if paths come # back as garbage while contents decode fine, this is the field that # is wrong. obfuscatePassphrase = config.sops.placeholder.obsidian_luna_passphrase; # Reads the chunking tweaks the plugin stored in the remote, instead # of guessing sizes that then disagree with every other client. useRemoteTweaks = true; baseDir = ""; } { type = "storage"; name = "luna-vault"; group = peerGroup; baseDir = vaultDir; # Catch up on anything that changed while the service was down. scanOfflineChanges = true; useChokidar = true; } ]; }; }; systemd.services.livesync-bridge = { description = "Obsidian LiveSync bridge (CouchDB <-> ${vaultDir})"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" "tailscaled.service" ]; wants = [ "network-online.target" ]; environment = { # Persistent module + npm cache. Without a fixed DENO_DIR the service # re-downloads its whole dependency tree on every start. DENO_DIR = "${stateDir}/deno"; # main.ts reads this instead of ./dat/config.json, which keeps the # secret out of the copied source tree entirely. LSB_CONFIG = config.sops.templates."livesync-bridge.json".path; LSB_HEALTH_FILE = "${stateDir}/health.json"; HOME = stateDir; }; # Copy the pinned source out of the store and install its locked deps. # It cannot run from /nix/store directly: deno.jsonc sets # `nodeModulesDir: manual` with byonm, so `deno install` must write a # node_modules/ next to the sources. # # The copy target is a FIXED path on purpose. Deno keys localStorage — # which is where the bridge records per-file sync state (Peer.ts:119) — by # the main module's origin, and stores it under # DENO_DIR/location_data/. VERIFIED by running the same # source from two paths against one DENO_DIR: two separate origin dirs # appear. Running straight from /nix/store would therefore change the # origin on every input bump and silently reset the bridge to a full # rescan of both peers. # # Guarded by a stamp file so this is a no-op on ordinary restarts; only a # flake input bump pays for the re-install (which needs network). preStart = '' set -eu stamp=${stateDir}/.src if [ "$(cat "$stamp" 2>/dev/null || true)" != "${inputs.livesync-bridge}" ]; then # Contents only — appDir is this unit's WorkingDirectory, and # deleting the cwd out from under deno breaks the install below. find ${appDir} -mindepth 1 -delete cp -r ${inputs.livesync-bridge}/. ${appDir}/ chmod -R u+w ${appDir} ${pkgs.deno}/bin/deno install --frozen printf '%s' "${inputs.livesync-bridge}" > "$stamp" fi ''; serviceConfig = { User = "hermes"; Group = "hermes"; StateDirectory = "livesync-bridge"; WorkingDirectory = appDir; # `deno task run` is `deno run -A main.ts`; invoked directly so the # task runner is not in the supervision path. ExecStart = "${pkgs.deno}/bin/deno run -A main.ts"; # main.ts installs an unhandledrejection guard, but a genuinely dead # process should still come back rather than trip the start limit. Restart = "always"; RestartSec = 30; # Group-writable output, so the two identities stay interchangeable if # the uid sharing above is ever unpicked. UMask = "0007"; }; }; }