[Foundry] Decide & document connection topology: self-hosted relay vs direct URL, and the SSRF/URL-policy exemption #143

Closed
opened 2026-07-15 22:02:32 +00:00 by claude-bot · 8 comments
Collaborator

Motivation/Context

Live NPC/token push (see the FoundryVTT live-push investigation, 2026-07-15) needs a hard architectural decision before any of #5's connection settings work can be considered final: how does the Quest Board backend actually reach a GM's Foundry instance, given that Foundry is frequently localhost:30000 on a GM's PC, or on a home LAN/VPN, never reliably reachable by direct inbound HTTP from the backend (#24 already calls this out explicitly)? The investigation evaluated three options: (A) direct backend→Foundry REST module HTTP call, (B) an outbound WebSocket relay — the ThreeHats foundryvtt-rest-api module already named in #24 is architected exactly this way, with the Foundry-side module dialing out to a relay server and the backend talking REST to the relay — and (C) browser-mediated push (rejected: HTTPS→HTTP mixed-content blocking, no server-side retries/idempotency, tab-dependent).

This also surfaces a real code-level collision: every existing admin-configured service URL is normalized through normalize_service_url() in webapp/backend/app/services/url_policy_service.py, called from settings_service.py (e.g. lines 167–171, 187–192) with private-IP targets blocked unless settings.allow_private_service_urls is set. A per-campaign, GM-settable Foundry/relay URL pointing at a LAN address is by design for this feature — so #5's connection-settings shape cannot be finalized until this issue resolves whether/how the Foundry URL is exempted from that SSRF guard.

Approach

Recommended architecture per the investigation: Option B (relay) as the primary mechanism, with Option A (direct URL) as a degenerate case of the same code path — foundry/client.py (per #24) just speaks to "a base URL + API key"; whether that URL is a self-hosted relay or a directly reachable Foundry endpoint is a per-campaign configuration detail, not an architectural fork.

For the SSRF collision, the investigation's recommended resolution (the safer of the two options considered) is:

  • Admin sets the relay URL once, stored in app_settings like any other admin-configured service endpoint (no SSRF exposure — it's an admin, not GM, setting).
  • GMs only paste their world's API key — no campaign-level arbitrary URL field at all, which removes campaign-level SSRF entirely.
  • The alternative (exempt a per-campaign Foundry URL from normalize_service_url with its own allowlist policy) should be documented as rejected, with the reasoning, if this issue confirms the admin-relay approach.

Concrete deliverables for this issue:

  1. Validate the foundryvtt-rest-api module's relay mode against the actual production Foundry instance and core version (per #24's explicit sequencing warning — this must happen before adapter code in #7 is written).
  2. Confirm whether the self-hostable relay server should run as a small Docker service alongside the Quest Board stack, or whether the community-hosted relay is acceptable for v1.
  3. Write up the final decision (relay vs direct, admin-URL vs per-campaign-URL, SSRF-policy interaction) as a short design note referenced by #5 and #7.
  4. Confirm the relay's "at least one connected Foundry client" requirement and document it as a caveat for #8 (scheduled member sync, which may run when nobody is in the world) — not a blocker for this issue, but must be recorded.

Dependencies

  • Amends/extends #4 (install/validate module) and #5 (per-campaign connection settings) in v4.0.0 — this issue's decision is a precondition for finalizing #5's settings schema.
  • Informs #6 (health check) and #7 (adapter scaffolding), which should not begin adapter-level work until this is validated (per #24's sequencing rule).

Out of scope

  • Writing the relay server itself (use the existing open-source self-hostable relay, don't build one).
  • Adapter code (npc_to_actor, etc.) — tracked separately in #7/#9.
  • Live scene placement, token push, or any Phase 2.5 functionality — this issue is purely the connectivity/topology decision.

Acceptance criteria

  • The foundryvtt-rest-api module's relay mode has been validated end-to-end against the real production Foundry instance (tested module + core version pair recorded).
  • A written decision exists (in this issue or linked doc) on: relay vs direct URL as the primary mechanism, and admin-set relay URL + GM-pasted API key vs per-campaign arbitrary URL.
  • The SSRF/URL-policy interaction is explicitly resolved and documented — either the Foundry URL never reaches GM-controlled input (preferred), or a specific, justified exemption to normalize_service_url is specified.
  • #5's connection-settings design references this issue's decision rather than re-deciding it.
## Motivation/Context Live NPC/token push (see the FoundryVTT live-push investigation, 2026-07-15) needs a hard architectural decision before any of #5's connection settings work can be considered final: how does the Quest Board backend actually reach a GM's Foundry instance, given that Foundry is frequently `localhost:30000` on a GM's PC, or on a home LAN/VPN, never reliably reachable by direct inbound HTTP from the backend (#24 already calls this out explicitly)? The investigation evaluated three options: (A) direct backend→Foundry REST module HTTP call, (B) an outbound WebSocket relay — the ThreeHats `foundryvtt-rest-api` module already named in #24 is architected exactly this way, with the Foundry-side module dialing *out* to a relay server and the backend talking REST to the relay — and (C) browser-mediated push (rejected: HTTPS→HTTP mixed-content blocking, no server-side retries/idempotency, tab-dependent). This also surfaces a real code-level collision: every existing admin-configured service URL is normalized through `normalize_service_url()` in `webapp/backend/app/services/url_policy_service.py`, called from `settings_service.py` (e.g. lines 167–171, 187–192) with private-IP targets blocked unless `settings.allow_private_service_urls` is set. A per-campaign, GM-settable Foundry/relay URL pointing at a LAN address is *by design* for this feature — so #5's connection-settings shape cannot be finalized until this issue resolves whether/how the Foundry URL is exempted from that SSRF guard. ## Approach Recommended architecture per the investigation: **Option B (relay) as the primary mechanism, with Option A (direct URL) as a degenerate case of the same code path** — `foundry/client.py` (per #24) just speaks to "a base URL + API key"; whether that URL is a self-hosted relay or a directly reachable Foundry endpoint is a per-campaign configuration detail, not an architectural fork. For the SSRF collision, the investigation's recommended resolution (the safer of the two options considered) is: - **Admin sets the relay URL once**, stored in `app_settings` like any other admin-configured service endpoint (no SSRF exposure — it's an admin, not GM, setting). - **GMs only paste their world's API key** — no campaign-level arbitrary URL field at all, which removes campaign-level SSRF entirely. - The alternative (exempt a per-campaign Foundry URL from `normalize_service_url` with its own allowlist policy) should be documented as rejected, with the reasoning, if this issue confirms the admin-relay approach. Concrete deliverables for this issue: 1. Validate the `foundryvtt-rest-api` module's relay mode against the actual production Foundry instance and core version (per #24's explicit sequencing warning — this must happen before adapter code in #7 is written). 2. Confirm whether the self-hostable relay server should run as a small Docker service alongside the Quest Board stack, or whether the community-hosted relay is acceptable for v1. 3. Write up the final decision (relay vs direct, admin-URL vs per-campaign-URL, SSRF-policy interaction) as a short design note referenced by #5 and #7. 4. Confirm the relay's "at least one connected Foundry client" requirement and document it as a caveat for #8 (scheduled member sync, which may run when nobody is in the world) — not a blocker for this issue, but must be recorded. ## Dependencies - Amends/extends #4 (install/validate module) and #5 (per-campaign connection settings) in v4.0.0 — this issue's decision is a precondition for finalizing #5's settings schema. - Informs #6 (health check) and #7 (adapter scaffolding), which should not begin adapter-level work until this is validated (per #24's sequencing rule). ## Out of scope - Writing the relay server itself (use the existing open-source self-hostable relay, don't build one). - Adapter code (`npc_to_actor`, etc.) — tracked separately in #7/#9. - Live scene placement, token push, or any Phase 2.5 functionality — this issue is purely the connectivity/topology decision. ## Acceptance criteria - The `foundryvtt-rest-api` module's relay mode has been validated end-to-end against the real production Foundry instance (tested module + core version pair recorded). - A written decision exists (in this issue or linked doc) on: relay vs direct URL as the primary mechanism, and admin-set relay URL + GM-pasted API key vs per-campaign arbitrary URL. - The SSRF/URL-policy interaction is explicitly resolved and documented — either the Foundry URL never reaches GM-controlled input (preferred), or a specific, justified exemption to `normalize_service_url` is specified. - #5's connection-settings design references this issue's decision rather than re-deciding it.
Author
Collaborator

Owner, 27 Oct 2026: give this serious consideration when v5.0.0 starts, and evaluate every option before choosing. The owner's first idea: our own Foundry module (plugin) that runs in the GM's world and connects back to Quest Board. That would be an outbound connection from Foundry, so it works behind home NAT with no inbound port, a third-party relay or a GM-entered URL.

The evaluation should at least cover:

  • (A) Direct backend → Foundry URL.
  • (B) The ThreeHats foundryvtt-rest-api module and its relay, self-hosted or theirs.
  • (D) A Quest Board Foundry module that dials out to our service. Pairing would be a code or token from campaign settings; the transport a WebSocket or long-poll.
  • (E) Anything else the research turns up: Foundry's own socket API, a companion desktop agent, and so on.

Criteria:

  • Works for a GM on localhost:30000 behind NAT.
  • Works for the hosted service (v6.0.0 follows), including SSRF exposure and per-tenant isolation.
  • Install burden for a GM.
  • Version churn: Foundry core upgrades break modules, so who maintains it.
  • Distribution: Foundry package listing, manifest URL.
  • Auth and pairing.
  • Reconnection and idempotency.
  • What a live push (v5.1.0) needs, such as latency and server-initiated messages.

Moved ahead of the hosted service (Foundry is now v5.0.0 and v5.1.0; hosted is v6.0.0), so the outcome must already suit hosted. #4 (install the REST module) depends on the choice made here.

**Owner, 27 Oct 2026: give this serious consideration when v5.0.0 starts, and evaluate every option before choosing.** The owner's first idea: **our own Foundry module (plugin)** that runs in the GM's world and connects *back* to Quest Board. That would be an outbound connection from Foundry, so it works behind home NAT with no inbound port, a third-party relay or a GM-entered URL. The evaluation should at least cover: - **(A)** Direct backend → Foundry URL. - **(B)** The ThreeHats `foundryvtt-rest-api` module and its relay, self-hosted or theirs. - **(D)** A Quest Board Foundry module that dials out to our service. Pairing would be a code or token from campaign settings; the transport a WebSocket or long-poll. - **(E)** Anything else the research turns up: Foundry's own socket API, a companion desktop agent, and so on. **Criteria:** - Works for a GM on `localhost:30000` behind NAT. - Works for the **hosted service** (v6.0.0 follows), including SSRF exposure and per-tenant isolation. - Install burden for a GM. - Version churn: Foundry core upgrades break modules, so who maintains it. - Distribution: Foundry package listing, manifest URL. - Auth and pairing. - Reconnection and idempotency. - What a live push (v5.1.0) needs, such as latency and server-initiated messages. Moved ahead of the hosted service (Foundry is now v5.0.0 and v5.1.0; hosted is v6.0.0), so the outcome must already suit hosted. #4 (install the REST module) depends on the choice made here.
Author
Collaborator

How Quest Board connects to a Foundry world (#143)

Design note, 2026-11-06, for the owner's decision before v5.0.0 starts. Researched from primary sources where cited; the Foundry 14.368 release (16 Sep 2026) was re-checked against foundryvtt.com.

The problem

Quest Board needs to read from and write to a GM's Foundry world: actors, journals, scenes, and live events during play. The world often runs at localhost:30000 on the GM's PC or on a home network, so Quest Board's server usually cannot reach it, and a hosted Quest Board never can.

Facts that shape every option

  • Foundry modules run only in the browser. Module scripts are "loaded and parsed by the browser when a game is loaded". They affect the game view, not the setup or join screens (module development). There is no server-side module code. So any module-based link exists only while someone has the world open in a browser. This is the "a client must be connected" caveat #143 already noted, and it applies to every module option, not just ThreeHats.
  • Foundry has no official REST API. Reaching world data from outside means either a module inside a client, or a headless browser logged in as a user.
  • Current Foundry: version 14, latest stable 14.368 (16 Sep 2026) (release notes). Major versions arrive about once a year (v12 2024, v13 2025, v14 2026; from memory, so approximate), and a module declares minimum, verified and maximum core versions in its manifest (compatibility data).
  • Distribution: a module is installed from a manifest URL (stable module.json plus a zip download) (module development). A listing on foundryvtt.com is optional. Unverified: the listing's current submission rules.
  • Browser rules:
    • A WebSocket is not subject to CORS, so a module in the browser can open wss:// to any server.
    • A fetch() to another origin needs that server to send CORS headers. Community modules that call outside APIs fail exactly here (Kenku issue, DiscordConnect issue).
    • An https Foundry page cannot open plain ws:// (mixed content). An http://localhost page can open wss://.
    • Unverified: whether Foundry v14 sends a Content-Security-Policy that restricts module connections. I found no official statement. The ThreeHats module working through a remote relay suggests outbound wss:// is allowed.

The options

A. Direct: the Quest Board backend calls a URL on the Foundry side

How it works: the GM enters a URL, and the backend calls a REST endpoint served by a module or proxy next to Foundry.

  • Foundry has no native REST, so something still has to run in a client or a headless browser to serve it.
  • It fails behind NAT and on localhost unless the GM opens a port or runs a tunnel.
  • For a hosted service it is an SSRF hole by design: a GM-chosen URL fetched by our server, exactly what normalize_service_url() exists to block (webapp/backend/app/services/url_policy_service.py, which rejects private-IP targets unless allow_private_ip_targets).

B. ThreeHats foundryvtt-rest-api module plus its relay

How it works: the module, in the GM's browser, dials out over WebSocket to a relay, and Quest Board calls the relay's REST API with an x-api-key (module, relay).

  • The relay comes as a public hosted service (foundryrestapi.com: account plus API key; the free tier is 100 requests/month and 1,000/day) or self-hosted. The self-hosted version is a Node server with its own accounts, password resets (SMTP), and SQLite or Postgres plus Redis, on Docker port 3010 (relay).
  • The module runs in a full GM's browser (role 4; not an Assistant GM). Its token lives in that browser's localStorage. The relay holds one connection per world: the first GM to connect holds the slot, and others get close code 4004 and wait. Pairing is per browser, by a "Pair via Browser" page or a 6-character code that expires in 10 minutes. It pings every 30 s and makes up to 20 reconnect attempts with doubling backoff (module docs).
  • Headless: the relay has a /session endpoint for "Headless Foundry sessions via Puppeteer" (relay). Community docs describe an auto-start of a headless session when the world is offline. Unverified against the official docs. It implies storing a Foundry login somewhere.
  • The API is broad: CRUD, search, roll, macro, encounter, and an execute-js permission (GM by default) (module docs). A leaked key is close to full control of the world.
  • License and project size: MIT. A small project: about 22 stars, 12 to 19 forks, 119 module commits and 286 relay commits. Unverified: release cadence, and which Foundry versions it supports. The pages fetched did not show either.

D. Our own Quest Board Foundry module that dials out to Quest Board

How it works: the GM installs "Quest Board" from a manifest URL. In campaign settings, Quest Board shows a pairing code. The GM types it into the module, which receives a campaign-scoped token. While a GM has the world open, the module holds a wss:// connection to Quest Board's own backend.

  • Narrow command set: create or update actor, journal page and item, switch scene, and report events. No arbitrary JS.
  • No relay and no second account system. Quest Board is the relay.
  • Backend work: a FastAPI WebSocket endpoint behind nginx with upgrade headers. With several uvicorn workers, messages for a campaign are routed to whichever worker holds its socket via Redis pub/sub on DB 0, never DB 1 or 2. Plus a queue of pending commands per campaign.
  • Starting point: the module could start as a fork of the ThreeHats module (MIT), keeping its pairing and reconnect logic and cutting the command set down. Or it could be written from scratch. It is a small amount of browser code either way.

E. Other routes considered

  • A headless browser that logs into Foundry as a user, run by Quest Board or by a companion service. It works with nobody online, but it needs a stored Foundry login, a browser per world, and something on the GM's network to reach localhost. It suits a later "sync while nobody is in" add-on, not the main link.
  • A companion desktop agent on the GM's PC. It still needs a browser session to touch world data, because modules are client-only. It adds an install with no gain over D.
  • Foundry's own socket protocol from a script. Undocumented, and breaks on core updates. Rejected.
  • Another listed bridge package ("foundry-api-bridge", seen in search results: listing). Not evaluated.

Scored against the owner's criteria

Key: ✔ good · ~ workable with caveats · ✘ poor

Criterion A. Direct URL B. ThreeHats + relay D. Our module E. Headless
GM on localhost behind NAT ✘ needs a port or tunnel ✔ outbound WebSocket ✔ outbound WebSocket to Quest Board ~ needs something on the GM's network
Hosted service: SSRF ✘ GM URL fetched by our server ~ none if the relay URL is set by an admin; public relay is a third party ✔ none; the browser connects in, our server fetches nothing ✘ stored logins; reaching the GM's network
Hosted service: tenant isolation ~ ~ relay accounts are separate from Quest Board's; one key per relay account ✔ token scoped to one campaign, issued by Quest Board ✘
GM install burden ✘ module + networking ~ module + relay account + API key; self-hosters also run the relay ✔ module + one pairing code ✘
Version churn, and who maintains it depends on the module ~ ThreeHats (small project, one maintainer as far as visible) ~ us, once a year per Foundry major; small surface ✘ breaks with the UI
Distribution — ✔ manifest URL; listed ✔ manifest URL served by Quest Board or a Forgejo release; listing optional —
Auth and pairing ✘ GM-pasted secrets ✔ pairing code, per browser ✔ pairing code from campaign settings, campaign-scoped token, revocable ✘ stored passwords
Reconnection and idempotency ~ ✔ backoff reconnect; one GM slot ✔ the same pattern, plus our command queue with ids, so a push lands once ~
v5.1 live push (low latency, server-initiated) ~ only if reachable ✔ but through a third hop and its rate limits ✔ direct socket; server can send at any time ~
#689 scene and map pairing ~ ~ possible through generic hooks or execute-js ✔ module hooks scene changes and reports them; Quest Board asks the module to activate a scene ~

Shared limitation of B and D: nothing happens while no GM has the world open. For v5.0 that's acceptable if pushes queue in Quest Board and the module drains the queue when a GM connects. Member sync (#8) becomes "sync when the GM opens the world", not "scheduled".

Recommendation

D: our own module, connecting out to Quest Board, with a pairing code.

  • It is the only option that works the same for a self-hoster on localhost and for the hosted service.
  • It adds no SSRF surface, because our server never fetches a GM-supplied address.
  • It isolates tenants with a campaign-scoped token that Quest Board issues and can revoke.
  • It needs no third-party account or relay.
  • The cost is maintaining a small browser module against each Foundry major, about once a year. B doesn't remove that cost; it moves it to a small outside project we don't control, and adds a relay and a second account system.

Before committing, a short spike on the owner's Foundry instance (its core version is not yet known; record it) (this replaces #4's "install the REST module"):

  1. A minimal module that pairs.
  2. Holds a wss:// connection to a dev Quest Board.
  3. Creates one actor.
  4. Reports one scene change.

This proves the browser and CSP assumptions marked unverified above. Reading the ThreeHats module's source first is worthwhile; forking it is allowed by its MIT licence.

What it changes in the v5.0 issues

  • #4 (install and validate the REST module): becomes "spike: the Quest Board module pairs and round-trips on the production Foundry instance". Record the tested core version.
  • #5 (per-campaign connection settings): no URL field at all. Settings show the pairing code, a connected or disconnected state, the paired world's name and core version, and "Unpair". The token is stored like BOT_API_KEY (#24) and never logged.
  • #6 (health check): becomes "last seen" plus a ping over the socket. A health check can't reach a world that isn't open, so the UI says "Not connected: open your world in Foundry".
  • #7 (adapter scaffolding): unchanged in spirit. Adapters translate on the backend, and the module only applies documents. foundry/client.py speaks to a socket instead of HTTP.
  • #8 (member sync): runs when the module connects, plus on updateActor events. Not on a timer.
  • SSRF guard: no exemption to normalize_service_url() is needed. #143's option of an admin-set relay URL becomes moot, and a per-campaign URL is never stored.

Risks

  • Foundry majors break the module: about once a year, we fix and release. Pin verified, and test on each Foundry release candidate.
  • One GM's browser at a time: if two GMs have the world open, only one should hold the link. Copy ThreeHats' first-holder-plus-handoff rule.
  • WebSockets across several uvicorn workers need the Redis routing. Get it right once and test it. nginx must pass upgrade headers, and the CSP's connect-src on Quest Board's own pages doesn't affect the module, because the module runs on Foundry's page.
  • Self-hosters on plain http with Foundry on https hit a mixed-content block. Document it: Quest Board expects TLS at the proxy anyway.
  • Unverified browser behaviour: Foundry's CSP. The spike settles it.

Decisions for the owner

  1. Which link should Quest Board use?
    • (a) our own module (D) (recommended)
    • (b) the ThreeHats module and relay (B)
    • (c) start with B to move fast, and replace it later
  2. Where does the module come from?
    • (a) a manifest URL served by each Quest Board server, so the module always matches that server's version (recommended)
    • (b) a Forgejo release
    • (c) also list it on foundryvtt.com later
  3. What happens to pushes while no GM has the world open?
    • (a) queue them in Quest Board, and deliver them when a GM opens the world (recommended)
    • (b) also offer a headless "always connected" mode later, which means storing a Foundry login
    • (c) refuse to push while the world is closed
  4. Who can pair a world?
    • (a) a campaign GM, with the owner able to unpair (recommended)
    • (b) the campaign owner only
  5. Spike first?
    • (a) yes, a short spike on your Foundry instance before anything else in v5.0, replacing #4 (recommended)
    • (b) go straight to building
# How Quest Board connects to a Foundry world (#143) Design note, 2026-11-06, for the owner's decision before v5.0.0 starts. Researched from primary sources where cited; the Foundry 14.368 release (16 Sep 2026) was re-checked against foundryvtt.com. ## The problem Quest Board needs to read from and write to a GM's Foundry world: actors, journals, scenes, and live events during play. The world often runs at `localhost:30000` on the GM's PC or on a home network, so Quest Board's server usually cannot reach it, and a hosted Quest Board never can. ## Facts that shape every option - **Foundry modules run only in the browser.** Module scripts are "loaded and parsed by the browser when a game is loaded". They affect the game view, not the setup or join screens ([module development](https://foundryvtt.com/article/module-development/)). There is no server-side module code. So **any module-based link exists only while someone has the world open in a browser.** This is the "a client must be connected" caveat #143 already noted, and it applies to every module option, not just ThreeHats. - **Foundry has no official REST API.** Reaching world data from outside means either a module inside a client, or a headless browser logged in as a user. - **Current Foundry:** version 14, latest stable 14.368 (16 Sep 2026) ([release notes](https://foundryvtt.com/releases/14.368)). Major versions arrive about once a year (v12 2024, v13 2025, v14 2026; from memory, so approximate), and a module declares `minimum`, `verified` and `maximum` core versions in its manifest ([compatibility data](https://foundryvtt.com/api/v14/interfaces/foundry.packages.types.PackageCompatibilityData.html)). - **Distribution:** a module is installed from a manifest URL (stable `module.json` plus a zip download) ([module development](https://foundryvtt.com/article/module-development/)). A listing on foundryvtt.com is optional. *Unverified: the listing's current submission rules.* - **Browser rules:** - A WebSocket is not subject to CORS, so a module in the browser can open `wss://` to any server. - A `fetch()` to another origin needs that server to send CORS headers. Community modules that call outside APIs fail exactly here ([Kenku issue](https://github.com/owlbear-rodeo/kenku-fm/issues/62), [DiscordConnect issue](https://github.com/caoranach/DiscordConnect/issues/14)). - An `https` Foundry page cannot open plain `ws://` (mixed content). An `http://localhost` page can open `wss://`. - *Unverified: whether Foundry v14 sends a Content-Security-Policy that restricts module connections.* I found no official statement. The ThreeHats module working through a remote relay suggests outbound `wss://` is allowed. ## The options ### A. Direct: the Quest Board backend calls a URL on the Foundry side How it works: the GM enters a URL, and the backend calls a REST endpoint served by a module or proxy next to Foundry. - Foundry has no native REST, so something still has to run in a client or a headless browser to serve it. - It fails behind NAT and on localhost unless the GM opens a port or runs a tunnel. - For a hosted service it is an SSRF hole by design: a GM-chosen URL fetched by our server, exactly what `normalize_service_url()` exists to block (`webapp/backend/app/services/url_policy_service.py`, which rejects private-IP targets unless `allow_private_ip_targets`). ### B. ThreeHats `foundryvtt-rest-api` module plus its relay How it works: the module, in the GM's browser, dials out over WebSocket to a relay, and Quest Board calls the relay's REST API with an `x-api-key` ([module](https://github.com/ThreeHats/foundryvtt-rest-api), [relay](https://github.com/ThreeHats/foundryvtt-rest-api-relay)). - **The relay** comes as a public hosted service (foundryrestapi.com: account plus API key; the free tier is 100 requests/month and 1,000/day) or self-hosted. The self-hosted version is a Node server with its own accounts, password resets (SMTP), and SQLite or Postgres plus Redis, on Docker port 3010 ([relay](https://github.com/ThreeHats/foundryvtt-rest-api-relay)). - **The module runs in a full GM's browser** (role 4; not an Assistant GM). Its token lives in that browser's localStorage. **The relay holds one connection per world:** the first GM to connect holds the slot, and others get close code 4004 and wait. Pairing is per browser, by a "Pair via Browser" page or a 6-character code that expires in 10 minutes. It pings every 30 s and makes up to 20 reconnect attempts with doubling backoff ([module docs](https://foundryrestapi.com/docs/foundry-module)). - **Headless:** the relay has a `/session` endpoint for "Headless Foundry sessions via Puppeteer" ([relay](https://github.com/ThreeHats/foundryvtt-rest-api-relay)). Community docs describe an auto-start of a headless session when the world is offline. *Unverified against the official docs.* It implies storing a Foundry login somewhere. - **The API is broad:** CRUD, search, roll, macro, encounter, and an `execute-js` permission (GM by default) ([module docs](https://foundryrestapi.com/docs/foundry-module)). A leaked key is close to full control of the world. - **License and project size:** MIT. A small project: about 22 stars, 12 to 19 forks, 119 module commits and 286 relay commits. *Unverified: release cadence, and which Foundry versions it supports. The pages fetched did not show either.* ### D. Our own Quest Board Foundry module that dials out to Quest Board How it works: the GM installs "Quest Board" from a manifest URL. In campaign settings, Quest Board shows a pairing code. The GM types it into the module, which receives a campaign-scoped token. While a GM has the world open, the module holds a `wss://` connection to Quest Board's own backend. - **Narrow command set:** create or update actor, journal page and item, switch scene, and report events. No arbitrary JS. - **No relay and no second account system.** Quest Board is the relay. - **Backend work:** a FastAPI WebSocket endpoint behind nginx with upgrade headers. With several uvicorn workers, messages for a campaign are routed to whichever worker holds its socket via Redis pub/sub on DB 0, never DB 1 or 2. Plus a queue of pending commands per campaign. - **Starting point:** the module could start as a fork of the ThreeHats module (MIT), keeping its pairing and reconnect logic and cutting the command set down. Or it could be written from scratch. It is a small amount of browser code either way. ### E. Other routes considered - **A headless browser that logs into Foundry as a user, run by Quest Board or by a companion service.** It works with nobody online, but it needs a stored Foundry login, a browser per world, and something on the GM's network to reach localhost. It suits a later "sync while nobody is in" add-on, not the main link. - **A companion desktop agent on the GM's PC.** It still needs a browser session to touch world data, because modules are client-only. It adds an install with no gain over D. - **Foundry's own socket protocol from a script.** Undocumented, and breaks on core updates. Rejected. - **Another listed bridge package** ("foundry-api-bridge", seen in search results: [listing](https://foundryvtt.com/packages/foundry-api-bridge)). *Not evaluated.* ## Scored against the owner's criteria Key: ✔ good · ~ workable with caveats · ✘ poor | Criterion | A. Direct URL | B. ThreeHats + relay | D. Our module | E. Headless | |---|---|---|---|---| | GM on localhost behind NAT | ✘ needs a port or tunnel | ✔ outbound WebSocket | ✔ outbound WebSocket to Quest Board | ~ needs something on the GM's network | | Hosted service: SSRF | ✘ GM URL fetched by our server | ~ none if the relay URL is set by an admin; public relay is a third party | ✔ none; the browser connects in, our server fetches nothing | ✘ stored logins; reaching the GM's network | | Hosted service: tenant isolation | ~ | ~ relay accounts are separate from Quest Board's; one key per relay account | ✔ token scoped to one campaign, issued by Quest Board | ✘ | | GM install burden | ✘ module + networking | ~ module + relay account + API key; self-hosters also run the relay | ✔ module + one pairing code | ✘ | | Version churn, and who maintains it | depends on the module | ~ ThreeHats (small project, one maintainer as far as visible) | ~ us, once a year per Foundry major; small surface | ✘ breaks with the UI | | Distribution | — | ✔ manifest URL; listed | ✔ manifest URL served by Quest Board or a Forgejo release; listing optional | — | | Auth and pairing | ✘ GM-pasted secrets | ✔ pairing code, per browser | ✔ pairing code from campaign settings, campaign-scoped token, revocable | ✘ stored passwords | | Reconnection and idempotency | ~ | ✔ backoff reconnect; one GM slot | ✔ the same pattern, plus our command queue with ids, so a push lands once | ~ | | v5.1 live push (low latency, server-initiated) | ~ only if reachable | ✔ but through a third hop and its rate limits | ✔ direct socket; server can send at any time | ~ | | #689 scene and map pairing | ~ | ~ possible through generic hooks or `execute-js` | ✔ module hooks scene changes and reports them; Quest Board asks the module to activate a scene | ~ | **Shared limitation of B and D:** nothing happens while no GM has the world open. For v5.0 that's acceptable if pushes queue in Quest Board and the module drains the queue when a GM connects. Member sync (#8) becomes "sync when the GM opens the world", not "scheduled". ## Recommendation **D: our own module, connecting out to Quest Board, with a pairing code.** - It is the only option that works the same for a self-hoster on localhost and for the hosted service. - It adds no SSRF surface, because our server never fetches a GM-supplied address. - It isolates tenants with a campaign-scoped token that Quest Board issues and can revoke. - It needs no third-party account or relay. - The cost is maintaining a small browser module against each Foundry major, about once a year. B doesn't remove that cost; it moves it to a small outside project we don't control, and adds a relay and a second account system. **Before committing,** a short spike on the owner's Foundry instance (its core version is not yet known; record it) (this replaces #4's "install the REST module"): 1. A minimal module that pairs. 2. Holds a `wss://` connection to a dev Quest Board. 3. Creates one actor. 4. Reports one scene change. This proves the browser and CSP assumptions marked unverified above. Reading the ThreeHats module's source first is worthwhile; forking it is allowed by its MIT licence. ## What it changes in the v5.0 issues - **#4** (install and validate the REST module): becomes "spike: the Quest Board module pairs and round-trips on the production Foundry instance". Record the tested core version. - **#5** (per-campaign connection settings): no URL field at all. Settings show the pairing code, a connected or disconnected state, the paired world's name and core version, and "Unpair". The token is stored like `BOT_API_KEY` (#24) and never logged. - **#6** (health check): becomes "last seen" plus a ping over the socket. A health check can't reach a world that isn't open, so the UI says "Not connected: open your world in Foundry". - **#7** (adapter scaffolding): unchanged in spirit. Adapters translate on the backend, and the module only applies documents. `foundry/client.py` speaks to a socket instead of HTTP. - **#8** (member sync): runs when the module connects, plus on `updateActor` events. Not on a timer. - **SSRF guard:** no exemption to `normalize_service_url()` is needed. #143's option of an admin-set relay URL becomes moot, and a per-campaign URL is never stored. ## Risks - **Foundry majors break the module:** about once a year, we fix and release. Pin `verified`, and test on each Foundry release candidate. - **One GM's browser at a time:** if two GMs have the world open, only one should hold the link. Copy ThreeHats' first-holder-plus-handoff rule. - **WebSockets across several uvicorn workers** need the Redis routing. Get it right once and test it. nginx must pass upgrade headers, and the CSP's `connect-src` on Quest Board's own pages doesn't affect the module, because the module runs on Foundry's page. - **Self-hosters on plain http** with Foundry on https hit a mixed-content block. Document it: Quest Board expects TLS at the proxy anyway. - **Unverified browser behaviour:** Foundry's CSP. The spike settles it. ## Decisions for the owner 1. **Which link should Quest Board use?** - (a) our own module (D) (recommended) - (b) the ThreeHats module and relay (B) - (c) start with B to move fast, and replace it later 2. **Where does the module come from?** - (a) a manifest URL served by each Quest Board server, so the module always matches that server's version (recommended) - (b) a Forgejo release - (c) also list it on foundryvtt.com later 3. **What happens to pushes while no GM has the world open?** - (a) queue them in Quest Board, and deliver them when a GM opens the world (recommended) - (b) also offer a headless "always connected" mode later, which means storing a Foundry login - (c) refuse to push while the world is closed 4. **Who can pair a world?** - (a) a campaign GM, with the owner able to unpair (recommended) - (b) the campaign owner only 5. **Spike first?** - (a) yes, a short spike on your Foundry instance before anything else in v5.0, replacing #4 (recommended) - (b) go straight to building
Author
Collaborator

Decided (owner, 2026-11-06, Q233–Q237; recorded as D333–D337):

  1. Our own Quest Board module (option D), not ThreeHats.
  2. Distribution: each Quest Board server serves its own manifest URL, and a foundryvtt.com listing later.
  3. Closed world: pushes queue and are delivered when a GM opens the world, and the campaign shows what's waiting. No headless mode for now.
  4. Pairing: any GM of the campaign; the owner can unpair.
  5. Test first, before anything else in v5.0. This replaces #4. The owner is building a test Foundry instance for it ("I'll build a test/dev Foundry instance for this"); record its core version.

Next: rewrite #4 as the test, and update #5, #6, #7 and #8 to match the "What it changes" section above when v5.0 starts.

**Decided (owner, 2026-11-06, Q233–Q237; recorded as D333–D337):** 1. **Our own Quest Board module** (option D), not ThreeHats. 2. **Distribution:** each Quest Board server serves its own manifest URL, **and a foundryvtt.com listing later**. 3. **Closed world:** pushes queue and are delivered when a GM opens the world, and the campaign shows what's waiting. No headless mode for now. 4. **Pairing:** any GM of the campaign; the owner can unpair. 5. **Test first**, before anything else in v5.0. This replaces #4. The owner is building a test Foundry instance for it ("I'll build a test/dev Foundry instance for this"); record its core version. Next: rewrite #4 as the test, and update #5, #6, #7 and #8 to match the "What it changes" section above when v5.0 starts.
Author
Collaborator

The test Foundry instance for D337 is live (2026-11-06)

This is the instance the owner said they'd build. Infra is tracked in Rhoving/iac-repo#874 (PR #875). Everything below was checked on the running server today.

Versions (D337: "record its core version")

Test instance Prod (VM 128, for comparison)
Foundry core 14.369.0 (generation 14, build 369) 14.369.0
Node.js 24.22.1 —
pf2e 8.7.0 (compatibility min 14.361, verified 14.370, max 14) 8.6.1
dnd5e 6.0.6 (min 14.367, verified 14) not installed

The core version matches prod on purpose, and the iac role keeps it pinned. A core bump on one should move the other with it. pf2e on the test instance is one minor ahead of prod; keep that in mind if a pf2e mapping behaves differently on prod.

Where it is

  • URL: https://foundry-dev.lan.ryanbrooks.dev. LAN only, through int-proxy (TLS ends there), with no public DNS. Away from home it needs the VPN.
  • Host: docker-test, container foundry-dev (image felddy/foundryvtt:14.369.1, pinned by digest), host port 18130. Data is in /opt/foundry-dev/data, owned by uid/gid 1226 (foundry-dev).
  • Licence: a second licence used only by this instance, so it never clashes with a game on prod. The EULA is signed and licence verification succeeded.
  • Admin key: the owner's 1Password.
  • Data is throwaway, with no backup. Don't build anything here you'd miss.
  • Claude sessions on claude-dev can't resolve *.lan.ryanbrooks.dev (that host doesn't use the Pi-holes). Use curl --resolve foundry-dev.lan.ryanbrooks.dev:443:192.168.1.30 …, or http://10.1.1.14:18130 directly from the LAN.

Facts for the test and the module

  • The page is https://, so the module must use wss://. ws:// is blocked as mixed content. Dev Quest Board (questboard-dev.rhoving.com) is HTTPS, so wss://questboard-dev.rhoving.com/... is the target. Its nginx must pass the upgrade headers. The edge Caddy passes WebSockets as-is.
  • No Content-Security-Policy. Checked on /auth, /join and /game, both straight from Foundry and through int-proxy. There's no CSP header and no <meta> CSP; the only security header is X-Frame-Options: DENY. That settles the design note's "Unverified: whether Foundry v14 sends a CSP" for 14.369: nothing in Foundry blocks an outbound wss:// from a module. Caveat: checked with no world running, so the test should still confirm the connection from inside a launched world.
  • Foundry sends Access-Control-Allow-Origin: *. That covers requests to Foundry, not the module's requests from the page. A fetch() from the module to Quest Board still needs Quest Board's CORS headers for https://foundry-dev.lan.ryanbrooks.dev. WebSocket handshakes aren't subject to CORS, though the backend should check Origin itself.
  • Outbound from the Foundry server works, to foundryvtt.com and to questboard-dev.rhoving.com, both 200. So installing from a manifest URL served by dev Quest Board (D334) will work.
  • Module development settings are on: hot reload on (hotReload: true, so CSS/HTML/lang changes apply without a refresh) and minification off (readable stack traces). Proxy settings: hostname: foundry-dev.lan.ryanbrooks.dev, proxySSL: true, proxyPort: 443, no route prefix.

Installing a test module

  • From a manifest: Setup → Add-on Modules → Install Module → the manifest URL served by dev Quest Board (D334).
  • From a working copy, faster for the test: copy the module folder to /opt/foundry-dev/data/Data/modules/<id>/ on docker-test, then sudo chown -R 1226:1226 it, and return to Setup (or reload the world).

Still needed before the test

  • No world exists yet. Systems are installed, but Data/worlds is empty. The test needs a world (pf2e is the main target) with the module enabled.
  • Two GMs: the "one GM's browser at a time" rule (first-holder plus handoff) needs a second GM user in that world to test.

Operating it

  • Logs: ssh docker-test 'sudo docker logs -f foundry-dev'.
  • Restart: sudo systemctl restart foundry-dev.
  • Foundry rewrites its config from the compose environment on every start, so settings changed in Foundry's Configuration tab don't survive a restart. Change them in iac-repo foundry-dev-ansible/ (README there).
## The test Foundry instance for D337 is live (2026-11-06) This is the instance the owner said they'd build. Infra is tracked in Rhoving/iac-repo#874 (PR #875). Everything below was checked on the running server today. ### Versions (D337: "record its core version") | | Test instance | Prod (VM 128, for comparison) | |---|---|---| | Foundry core | **14.369.0** (generation 14, build 369) | 14.369.0 | | Node.js | 24.22.1 | — | | pf2e | **8.7.0** (compatibility min 14.361, verified 14.370, max 14) | 8.6.1 | | dnd5e | **6.0.6** (min 14.367, verified 14) | not installed | The core version matches prod on purpose, and the iac role keeps it pinned. A core bump on one should move the other with it. pf2e on the test instance is one minor ahead of prod; keep that in mind if a pf2e mapping behaves differently on prod. ### Where it is - **URL:** https://foundry-dev.lan.ryanbrooks.dev. **LAN only**, through int-proxy (TLS ends there), with no public DNS. Away from home it needs the VPN. - **Host:** docker-test, container `foundry-dev` (image `felddy/foundryvtt:14.369.1`, pinned by digest), host port `18130`. Data is in `/opt/foundry-dev/data`, owned by uid/gid 1226 (`foundry-dev`). - **Licence:** a second licence used only by this instance, so it never clashes with a game on prod. The EULA is signed and licence verification succeeded. - **Admin key:** the owner's 1Password. - **Data is throwaway**, with no backup. Don't build anything here you'd miss. - **Claude sessions on claude-dev** can't resolve `*.lan.ryanbrooks.dev` (that host doesn't use the Pi-holes). Use `curl --resolve foundry-dev.lan.ryanbrooks.dev:443:192.168.1.30 …`, or `http://10.1.1.14:18130` directly from the LAN. ### Facts for the test and the module - **The page is `https://`, so the module must use `wss://`.** `ws://` is blocked as mixed content. Dev Quest Board (`questboard-dev.rhoving.com`) is HTTPS, so `wss://questboard-dev.rhoving.com/...` is the target. Its nginx must pass the upgrade headers. The edge Caddy passes WebSockets as-is. - **No Content-Security-Policy.** Checked on `/auth`, `/join` and `/game`, both straight from Foundry and through int-proxy. There's no CSP header and no `<meta>` CSP; the only security header is `X-Frame-Options: DENY`. That settles the design note's "Unverified: whether Foundry v14 sends a CSP" for 14.369: nothing in Foundry blocks an outbound `wss://` from a module. Caveat: checked with no world running, so the test should still confirm the connection from inside a launched world. - **Foundry sends `Access-Control-Allow-Origin: *`.** That covers requests *to* Foundry, not the module's requests *from* the page. A `fetch()` from the module to Quest Board still needs Quest Board's CORS headers for `https://foundry-dev.lan.ryanbrooks.dev`. WebSocket handshakes aren't subject to CORS, though the backend should check `Origin` itself. - **Outbound from the Foundry server works**, to foundryvtt.com and to `questboard-dev.rhoving.com`, both 200. So installing from a manifest URL served by dev Quest Board (D334) will work. - **Module development settings are on:** hot reload on (`hotReload: true`, so CSS/HTML/lang changes apply without a refresh) and minification off (readable stack traces). Proxy settings: `hostname: foundry-dev.lan.ryanbrooks.dev`, `proxySSL: true`, `proxyPort: 443`, no route prefix. ### Installing a test module - **From a manifest:** Setup → Add-on Modules → Install Module → the manifest URL served by dev Quest Board (D334). - **From a working copy, faster for the test:** copy the module folder to `/opt/foundry-dev/data/Data/modules/<id>/` on docker-test, then `sudo chown -R 1226:1226` it, and return to Setup (or reload the world). ### Still needed before the test - **No world exists yet.** Systems are installed, but Data/worlds is empty. The test needs a world (pf2e is the main target) with the module enabled. - **Two GMs:** the "one GM's browser at a time" rule (first-holder plus handoff) needs a second GM user in that world to test. ### Operating it - Logs: `ssh docker-test 'sudo docker logs -f foundry-dev'`. - Restart: `sudo systemctl restart foundry-dev`. - Foundry rewrites its config from the compose environment on every start, so settings changed in Foundry's Configuration tab don't survive a restart. Change them in iac-repo `foundry-dev-ansible/` (README there).
Author
Collaborator

Test world created (owner, 2026-11-06): The Amberwake Accord

  • World id: the-amberwake-accord. On docker-test it's at /opt/foundry-dev/data/Data/worlds/the-amberwake-accord/.
  • System: pf2e 8.7.0. Core 14.369.
  • Use this world for the D337 test: enable the module here, pair it, create the test actor, and report a scene change.

This clears the "no world yet" item in the comment above. Still open: a second GM user in this world, for testing first-holder plus handoff.

**Test world created (owner, 2026-11-06):** **The Amberwake Accord** - World id: `the-amberwake-accord`. On docker-test it's at `/opt/foundry-dev/data/Data/worlds/the-amberwake-accord/`. - System: **pf2e 8.7.0**. Core 14.369. - Use this world for the D337 test: enable the module here, pair it, create the test actor, and report a scene change. This clears the "no world yet" item in the comment above. Still open: a second GM user in this world, for testing first-holder plus handoff.
Author
Collaborator

Users in The Amberwake Accord (owner, 2026-11-06): the owner created two GM accounts. Read from the world's user data on the server (names and roles only):

  • Ryan: GM (role 4)
  • Test-GM: GM (role 4)
  • Player2: Player (role 1)

This clears the last setup item. The test can cover:

  • First holder plus handoff: open the world as Ryan in one browser, then as Test-GM in another (a private window works). Only one should hold the link. When the first closes, the other should take over.
  • A non-GM never holds the link: open the world as Player2. The module should not connect, or should refuse to pair.

Foundry's default "Gamemaster" user from world creation also showed up in the user data. It may be a stale record from before a rename. If it's still in the world's User Management, remove it or keep it in mind as a third GM.

**Users in The Amberwake Accord (owner, 2026-11-06):** the owner created two GM accounts. Read from the world's user data on the server (names and roles only): - **`Ryan`**: GM (role 4) - **`Test-GM`**: GM (role 4) - **`Player2`**: Player (role 1) This clears the last setup item. The test can cover: - **First holder plus handoff:** open the world as `Ryan` in one browser, then as `Test-GM` in another (a private window works). Only one should hold the link. When the first closes, the other should take over. - **A non-GM never holds the link:** open the world as `Player2`. The module should not connect, or should refuse to pair. Foundry's default "Gamemaster" user from world creation also showed up in the user data. It may be a stale record from before a rename. If it's still in the world's User Management, remove it or keep it in mind as a third GM.
Author
Collaborator

The D337 test passed (2026-11-07, #4). All seven checks passed on Foundry 14.369 with pf2e 8.7.0. Two findings shape the real module: the pairing UI must be registered for GMs only, because restricted doesn't hide client-scope settings, and it needs a lasting connection indicator. Details on #4. The connection design is confirmed.

**The D337 test passed (2026-11-07, #4).** All seven checks passed on Foundry 14.369 with pf2e 8.7.0. Two findings shape the real module: the pairing UI must be registered for GMs only, because `restricted` doesn't hide client-scope settings, and it needs a lasting connection indicator. Details on #4. The connection design is confirmed.
Author
Collaborator

Decided and proven; closing. The owner chose option D, our own module connecting out, as D333–D337 (2026-11-06). The #4 test passed 7/7 on Foundry 14.369 (2026-11-07). The build contract is docs/design/foundry-connection.md on feat/5-foundry-connect: pairing over the socket, per-GM tokens (stored as hashes, never logged), one holder per campaign across workers through Redis DB 0, and the module served by each server (D334). The build continues in #5 and #6 (slice 1, Connect), then #7, #8 and #9.

**Decided and proven; closing.** The owner chose option D, our own module connecting out, as D333–D337 (2026-11-06). The #4 test passed 7/7 on Foundry 14.369 (2026-11-07). The build contract is `docs/design/foundry-connection.md` on `feat/5-foundry-connect`: pairing over the socket, per-GM tokens (stored as hashes, never logged), one holder per campaign across workers through Redis DB 0, and the module served by each server (D334). The build continues in #5 and #6 (slice 1, Connect), then #7, #8 and #9.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#143
No description provided.