Safe-by-default dashboard exposure: localhost-only unless you explicitly opt in
to a public bind with a token.
- src/cli.ts: host default 0.0.0.0 → 127.0.0.1 (localhost-only; --host 0.0.0.0 to
expose, which then requires a token when the auth flag is on).
- src/server.ts startServer guards (before listen): SELF-LOCKOUT (flag on + no
token → throw — you'd otherwise brick the dashboard with no recovery short of
editing .env) + PUBLIC-EXPOSURE gate (flag on + 0.0.0.0 + no token → throw).
Both use __authState (mutable seam) so they're testable. __authState.bound set
from cfg.host. When the flag is off, the guards are skipped (back-compat
escape hatch — the bind still defaults to 127.0.0.1).
- src/server.ts authenticate: E7.2 bind-gating — enforce only on a PUBLIC bind
(0.0.0.0); 127.0.0.1 is localhost-trusted (requireAuth routes stay open even
with the flag on). The refuse-to-start guard ensures a 0.0.0.0 bind has a token.
- src/server.ts routes: GET /api/auth/status (open; {authRequired, bound,
relayConfigured, foundryConfigured} — booleans only, no secret values),
POST /api/auth/login (open; validates token constant-time, sets an HttpOnly
SameSite=Strict cookie; 401 invalid credentials on mismatch, no leak; empty
token = unset; no token configured → 401), POST /api/auth/logout (clears
cookie, Max-Age=0).
- src/dashboard.html: first-run login card (token input, shown when authRequired
&& no stored token), a shared apiFetch wrapper (attaches the stored token as a
Bearer header, on 401 → show login), checkAuth gating init; bare fetch('/api/')
calls migrated to apiFetch (auth endpoints stay plain fetch).
- .env.example: documents DASHBOARD_AUTH_TOKEN + ENABLE_AUTH_MIDDLEWARE.
- tests: e7-1-auth/dispatch updated for bind-gating (enforcement tests set
bound=0.0.0.0; + a 127.0.0.1 no-enforcement test). e7-2-auth.test.ts (13
tests): auth-status (off/localhost/public/no-secret-leak), login
(valid-cookie/invalid-401/empty/unset), logout (clears), refuse-to-start
(self-lockout / 0.0.0.0-no-token / token-set-passes / flag-off-no-guard).
tsc clean; 194 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
foundry-obsidian-sync
A strict, offline, bidirectional converter between Foundry VTT Campaign Codex journal data (a LevelDB snapshot) and an Obsidian vault. No Foundry instance, no macros, no network — just the exported DB.
Two markdown formats are bridged:
- Obsidian curated (
Roland Raventhorne.md) — frontmattertype/tags/aliases/ faction/region/race/portrait, italic tagline,## Appearance / Personality / Background / Goals / Secrets,[[Name|Display]]links. - Campaign Codex native export (
Roland Raventhornecc.md) — frontmattercc_id/cc_uuid/cc_type/cc_folder_path/cc_exported_at,# Title,## Information, body sections,#### Bio/#### Socialboxes,## Linked Sheets/### Associates,## Notes,[[Name]]links.
Foundry is the source of truth. Each Obsidian note carries a foundry: identity
block (cc_uuid, cc_type, folder_path, contentHash, syncedAt) so the two
sides stay linked across syncs and renames.
Quick start (Docker Compose)
Clone, then bring up the relay wired to your own external Foundry. You need: a running Foundry VTT (license + the rest-api module) and an Obsidian vault on disk that you edit in your own Obsidian desktop app.
cp .env.example .envand fill inFOUNDRY_URL,FOUNDRY_WORLD,RELAY_USER/RELAY_PASSWORD, andVAULT(or let the/setupskill do all of this for you — it runs the commands itself; you only paste values it can't know).docker compose up -d relay— relay on:3010.- Open
http://localhost:3010, sign up, and copy the API key into.envasRELAY_API_KEY. In Foundry, point the rest-api module atws(s)://<host-Foundry-can-reach>:3010. node scripts/start-relay-session.js— launches a headless Foundry session the relay drives../sync.sh refresh --out ./outthen./sync.sh ui --vault "$VAULT/Refined" \ --out ./out --host 0.0.0.0— opens the dashboard on:7788.
The sync tool stays host-run (it reads the journal LevelDB and shells out to
docker); the compose provides only the relay. You edit notes in your own Obsidian
desktop app pointed at $VAULT; the dashboard reads/writes the same files on disk.
Push edits back to Foundry from the dashboard (/api/push, /api/push-all), or
flip on Auto-sync mode to push Obsidian→Foundry automatically the moment you save
a note (Foundry→Obsidian stays manual — use Sync / Re-pull for that direction).
How it works
db.tsopens the journal LevelDB read-only withclassic-level, indexes every Campaign Codex JournalEntry, and resolves@UUID⇄ note name.htmlMd.tsports theMacros/cc-4-export.jsHTML→Markdown logic (tagline, sections, Bio/Social boxes) but resolves@UUIDto[[Name|Display]]via the index.toObsidian.ts/toFoundry.tsconvert each direction.toFoundrypullscc_id/associates/image from the matched Foundry entry (byfoundry:block, then name).normalize.tscanonicalizes wikilinks/whitespace and hashes content so cosmetic edits don't churn.
Modes (safety)
The tool defaults to dev mode and never risks the real world data.
--dev(default): reads your copy of the world data, writes only into--out. Sources are never mutated. Refuses--outequal to--journalor the source vault dir.--dry-run: computes outputs and prints diffs only — writes nothing.--apply: writes in place to the real vault/cc dir, writing a timestamped.bak-<iso>of every file it overwrites first. Gated explicitly.
The journal LevelDB is opened read-only in all modes; the code only ever calls
read methods (get/iterator), never put/del/batch.
Usage
npm install
# Prep a dev copy of your world data (you do this, the tool won't):
cp -r ~/hosting/"Lore examples"/journal /tmp/journal-copy
cp -r ~/hosting/"Lore examples" /tmp/lore-copy
# Foundry -> Obsidian (one entry by --id, or all if omitted)
npx tsx src/cli.ts to-obsidian --dev --journal /tmp/journal-copy --out /tmp/devout --id flGsAYaK24eUZhQE
# Obsidian -> cc.md (+ optional Foundry-importable JSON with --emit-json)
npx tsx src/cli.ts to-foundry --dev --journal /tmp/journal-copy \
--vault "/tmp/lore-copy/Roland Raventhorne.md" --out /tmp/devoutcc --emit-json
# See what would change, write nothing
npx tsx src/cli.ts to-foundry --dry-run --journal ... --vault ... --out ...
Batch dashboard — connect the whole vault at once
For more than one entry, the ui command starts a review dashboard over the batch
engine (binds 0.0.0.0 by default so it's reachable on your tailnet at this VM's IP,
port 7788; pass --host 127.0.0.1 to restrict to localhost). It indexes the journal
LevelDB + your cc export + your refined vault, shows every file's match status and
diff, and runs the three "connect" operations:
- Seed — inject the
foundry:identity block into each refined note (matched by filename →cc_id→ journal entry). Minimal: only the block is added, leaving all curation (curatedtype,aliases, status tags, hand-edited body, links) untouched. - Sync → cc — regenerate each
cc.mdfrom its curated refined note, so curation (new[[links]], edited prose) flows back into the Campaign Codex export. - Import — pull the cc-only entries (no refined counterpart) into new refined
notes under
refined/imported/<cc_folder>/for curation.
# Prep dev copies (you do this; the tool won't):
cp -r ~/hosting/"Lore examples"/journal /tmp/journal-copy
cp -r ~/hosting/"Lore examples" /tmp/lore-copy
npx tsx src/cli.ts ui --dev --journal /tmp/journal-copy \
--vault "/tmp/lore-copy/Obsidian vault/Land of Mardonar/Refined" \
--cc "/tmp/lore-copy/campaign codex" --out /tmp/devout
# → open http://127.0.0.1:7788
The dashboard opens in dev mode with dry-run on by default — actions preview
without writing. Uncheck dry-run to write into the --out sandbox. To write back to
the real refined/cc dirs (with timestamped .bak-<iso> backups), start with --apply
instead of --dev; the UI cannot escalate to apply unless the server was started
with it. The journal LevelDB is read-only in all modes.
API (for scripting): GET /api/status, GET /api/index, GET /api/file?name=,
POST /api/action with {op:"seed"|"sync"|"import"|"seedAll"|"syncAll"|"importAll", names?:[], dryRun?:bool}.
Test
npm test # 30 tests: links, Roland round-trip, dev/dry-run/apply safety, batch + server
Notes
status/*tags andaliasesare curation-only on the Obsidian side; they are preserved across sync, never sourced from or written to Foundry.--emit-jsonreconstructs a Foundry-importable JournalEntry JSON with clean (not byte-identical) description/notes HTML — for a future push back into a live Foundry world via the existing macros or a DB write, both out of scope here.- The cc.md format drops wiki-link display aliases (
[[Name|Display]]→[[Name]]), matching Campaign Codex's native export. Display text round-trips losslessly only through the Foundry JSON /foundry:-backed sync, not through cc.md.