fvtt-world-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ # Foundry compatibility
2
+
3
+ The bridge supports Foundry VTT v13 and v14. It capability-checks behavior whose availability or
4
+ semantics differ by version and returns a structured error when it cannot provide the documented
5
+ result honestly.
6
+
7
+ ## Operator contract
8
+
9
+ - `fvtt-world-cli system info --json` reports the connected Foundry, system, module, limits, and
10
+ advertised command set.
11
+ - `UNSUPPORTED_OPERATION` is a capability result, not a transient transport failure.
12
+ - A dry run validates a proposed mutation, but values that require execution are not a forecast.
13
+
14
+ ## Known differences
15
+
16
+ | Area | v13 | v14 | CLI behavior |
17
+ |---|---|---|---|
18
+ | Measured templates | Available | Removed from core | Template commands are capability-gated |
19
+ | Scene thumbnail rendering | Whole-scene behavior | Initial-level behavior | Reports the dimensions and stored path actually produced |
20
+ | Scene thumbnail files | Stable scene-based filename | Content-derived filenames | Consumers use the returned path; cleanup policy differs |
21
+ | Scene placeable fields | Older document models | Some families add fields | Open-family writes pass through sanitized data; reads expose the documented projection |
22
+ | Region behaviors | Core v13 type set | Additional core types | Executable core behavior types remain guarded; other types are Foundry-validated |
23
+ | Combat and action APIs | Version-specific signatures | Version-specific signatures | The bridge adapts known signatures and refuses unsupported behavior |
24
+
25
+ This table describes current operator-visible differences, not implementation evidence. Exact
26
+ capabilities remain defined by the connected bridge and exercised by live smoke tests.
@@ -0,0 +1,104 @@
1
+ # Getting started
2
+
3
+ This walkthrough covers the first run in detail: starting the daemon, pairing a Foundry GM client,
4
+ and confirming the bridge, with what to expect at each step and what to do when something deviates.
5
+
6
+ ## 1. Start the daemon
7
+
8
+ ```bash
9
+ fvtt-world-cli bridge serve
10
+ ```
11
+
12
+ The daemon is the meeting point of the other two parts: the Foundry module connects to it, the CLI
13
+ sends commands to it, and it routes each command into the GM session. It listens on
14
+ `ws://127.0.0.1:47833` unless overridden and stays running in the foreground for as long as the
15
+ tool is in use.
16
+
17
+ The daemon should be running before the Foundry GM client loads the world: the module tries to
18
+ connect once when the world loads, and if the daemon is down at that moment it gives up with a
19
+ warning. Connecting after that is a manual retry — Connect in the scene controls, or a client
20
+ reload.
21
+
22
+ ## 2. Pair the Foundry GM client
23
+
24
+ Pairing is how a browser gets its own permission to talk to the daemon; it happens once per
25
+ browser.
26
+
27
+ It starts in the module's Authorization window. The quickest way there is the `World CLI` group at
28
+ the bottom of the left scene controls; its icon doubles as a status light:
29
+
30
+ - muted grey — not paired yet
31
+ - amber — connecting
32
+ - green — the bridge is up
33
+ - red — stopped or failed
34
+
35
+ The same windows are also reachable through Configure Settings → Module Settings:
36
+
37
+ ![The module settings](images/module-settings.png)
38
+
39
+ The module finds the daemon through its `Daemon URL` setting. By default it already points where
40
+ the daemon listens, so there is nothing to configure; the setting exists for running the daemon on
41
+ a custom port or address, and then it has to match the URL that `bridge serve` prints at startup.
42
+
43
+ The `Browser label` field in the Authorization window is how this browser will be named in approval
44
+ prompts and in `auth list`. It is editable until pairing; after pairing it is fixed, and changing
45
+ it takes an Unpair followed by a new Pair. Each browser also carries its own permanent client
46
+ identifier, so several browsers stay paired side by side and a re-pair replaces only that browser's
47
+ record.
48
+
49
+ ![The Authorization window before pairing](images/authorization-window.png)
50
+
51
+ Choose Pair, then approve the request from a terminal on the daemon's machine — the approval is
52
+ what turns the request into a stored permission:
53
+
54
+ ```bash
55
+ fvtt-world-cli auth
56
+ ```
57
+
58
+ The command waits for the pairing request (starting it before or after clicking Pair both work),
59
+ shows who is asking — origin, world, GM, browser label, client id — and asks
60
+ `Approve pairing request <code>? [y/N]`. Typing `y` and pressing Enter approves it; any other
61
+ answer denies it. To read a request over before deciding, or to approve from a script, the same
62
+ approval exists as two steps:
63
+
64
+ ```bash
65
+ fvtt-world-cli auth pending
66
+ fvtt-world-cli auth approve <code> # add --yes in a script to skip the confirmation
67
+ ```
68
+
69
+ ## 3. Confirm the bridge
70
+
71
+ On approval the browser stores its pairing credential and starts the bridge immediately — no
72
+ reload is needed, and the scene-controls icon turns green. From now on the bridge connects on its
73
+ own as soon as the world loads: that is the client-scoped `Connect automatically` setting, enabled
74
+ by default; with it disabled the bridge stays offline until Connect is chosen.
75
+
76
+ A first command confirms the connection end to end:
77
+
78
+ ```bash
79
+ fvtt-world-cli system info --json
80
+ ```
81
+
82
+ It reports `bridge.status` as `connected`.
83
+
84
+ ## When something deviates
85
+
86
+ The Bridge status window (in the scene controls or the module settings) is the first place to
87
+ look: it names the connection state in the same colours as the icon and shows the daemon URL, the
88
+ last connection time, the reconnect attempts, and the reason behind a stop, updating itself while
89
+ open.
90
+
91
+ Two buttons cover recovery. Connect builds a fresh bridge client, which is also what clears a stop
92
+ such as `BRIDGE_BUSY` or `DAEMON_UNAVAILABLE`, and during a reconnect wait it retries immediately
93
+ instead of waiting out the backoff delay. Disconnect abandons the connection and tells the daemon
94
+ goodbye, so the daemon frees its active bridge slot right away instead of waiting out an
95
+ abnormal-disconnect lease.
96
+
97
+ One bridge is active at a time, so a second paired browser's connection attempt stops on
98
+ `BRIDGE_BUSY` and stays stopped. Switching browsers is a Disconnect on the active one followed by
99
+ Connect on the other — pairing plays no part in it.
100
+
101
+ Authorization holds the two exits. Unpair revokes this browser's access and removes its credential
102
+ once the daemon confirms the revocation. Forget local is the recovery for a daemon that cannot be
103
+ reached: it clears only the browser's side, and the daemon's record stays active until
104
+ `auth revoke <pairingId>` succeeds.
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" fill="#0b0a13"/>
3
+ <g fill="none" stroke="#fc6119" stroke-linecap="round" stroke-linejoin="round">
4
+ <g stroke-width="15">
5
+ <path d="M256 36 446.53 146V366L256 476 65.47 366V146Z"/>
6
+ <path d="M256 120 373.78 324H138.22Z"/>
7
+ <path d="M256 36 256 120M446.53 146 256 120M65.47 146 256 120"/>
8
+ <path d="M446.53 366 373.78 324M446.53 146 373.78 324M256 476 373.78 324"/>
9
+ <path d="M65.47 366 138.22 324M65.47 146 138.22 324M256 476 138.22 324"/>
10
+ </g>
11
+ <g stroke-width="12">
12
+ <path d="M223 240 251 268 223 296"/>
13
+ <path d="M266 296H306" stroke-linecap="butt"/>
14
+ </g>
15
+ </g>
16
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <g fill="none" stroke="#fc6119" stroke-linecap="round" stroke-linejoin="round">
3
+ <g stroke-width="15">
4
+ <path d="M256 36 446.53 146V366L256 476 65.47 366V146Z"/>
5
+ <path d="M256 120 373.78 324H138.22Z"/>
6
+ <path d="M256 36 256 120M446.53 146 256 120M65.47 146 256 120"/>
7
+ <path d="M446.53 366 373.78 324M446.53 146 373.78 324M256 476 373.78 324"/>
8
+ <path d="M65.47 366 138.22 324M65.47 146 138.22 324M256 476 138.22 324"/>
9
+ </g>
10
+ <g stroke-width="12">
11
+ <path d="M223 240 251 268 223 296"/>
12
+ <path d="M266 296H306" stroke-linecap="butt"/>
13
+ </g>
14
+ </g>
15
+ </svg>
Binary file
@@ -0,0 +1,238 @@
1
+ # Protocol
2
+
3
+ This document describes the stable integration contract between CLI clients, the local daemon, and
4
+ the Foundry bridge: what each party may assume, which guarantees survive reconnects and failures,
5
+ and how errors and retries are classified. It deliberately contains no wire-level inventory. Exact
6
+ message types, frame schemas, command names, request schemas, error codes, default timeouts, and
7
+ size limits are defined by the protocol package and discoverable at runtime:
8
+
9
+ - [`packages/protocol/src/commands.js`](../packages/protocol/src/commands.js)
10
+ - [`packages/protocol/src/schemas/`](../packages/protocol/src/schemas/)
11
+ - [`packages/protocol/src/constants.js`](../packages/protocol/src/constants.js)
12
+ - [`packages/protocol/src/validation.js`](../packages/protocol/src/validation.js)
13
+ - `fvtt-world-cli commands --json`, `fvtt-world-cli schema <command>`, and
14
+ `fvtt-world-cli system info --json` for the installed checkout and the connected runtime
15
+
16
+ ## Versioning
17
+
18
+ The protocol is versioned. Every transport message carries the protocol version, and an unsupported
19
+ version is rejected at the bridge handshake and during command handling rather than degraded
20
+ silently. The current version is `3.0`; matching is exact, so a daemon and a Foundry module from
21
+ different releases refuse each other at the handshake instead of negotiating a subset.
22
+
23
+ ## Transport model
24
+
25
+ The daemon listens on a loopback WebSocket endpoint. Credentials never appear in URLs. A local
26
+ client — the CLI or a future Companion — has no browser Origin and must establish its role with its
27
+ first message: the daemon enforces a short deadline for a valid first frame, closes malformed
28
+ openings immediately, and never assigns a role before authentication has succeeded. Browser sockets
29
+ are identified by their exact HTTP(S) Origin and participate only in pairing and bridge sessions;
30
+ they cannot assume the local-client role.
31
+
32
+ Every message type has a closed top-level schema. A malformed message yields a structured
33
+ `INVALID_MESSAGE` error where a response is possible, and an authenticated local client that sends
34
+ a malformed control request receives a correlated error and may keep using its connection.
35
+
36
+ The daemon accepts one active authenticated bridge at a time:
37
+
38
+ - another pairing cannot displace the active bridge and is rejected as `BRIDGE_BUSY`;
39
+ - a new socket from the same pairing takes the slot over — the tab-reload recovery path;
40
+ - a clean goodbye releases the slot immediately, while an abnormal close reserves it briefly for
41
+ the same pairing;
42
+ - a daemon-initiated release is terminal for the released client: it does not reconnect on its
43
+ own, and resuming is an explicit operator action.
44
+
45
+ ## Size limits
46
+
47
+ The upload limit and the transport frame limit are distinct: the first bounds raw upload content,
48
+ the second accounts for encoding and envelope overhead and is never lower than what legitimate
49
+ large read responses require. The daemon advertises its effective limits during the handshake, and
50
+ the bridge checks response size before sending, so an oversized response returns
51
+ `PAYLOAD_TOO_LARGE` instead of destroying the shared session. A persisted upload-limit change is
52
+ applied to the transport only after the daemon restarts.
53
+
54
+ ## Pairing
55
+
56
+ Pairing is the one-time exchange that lets a GM browser become a bridge. Its guarantees:
57
+
58
+ - a pending request is bound to the socket that made it and disappears when that socket closes;
59
+ - approval codes expire after a bounded interval;
60
+ - the daemon persists only a digest of the bridge credential; the clear credential is delivered
61
+ exactly once, to the requesting socket, only after the digest has been persisted;
62
+ - approving the same Origin/world/user/client again re-pairs that client's existing profile by
63
+ rotating its credential instead of accumulating duplicates;
64
+ - expiry, denial, socket close, and approval all end the attempt through one idempotent path, so
65
+ the browser-side authorization UI is never left waiting after daemon shutdown or expiry.
66
+
67
+ ### Client identity
68
+
69
+ The pairing request carries a `client` object inside its identity: `id` is the browser's persistent
70
+ client identifier, and `label` is a human name for that browser. Pairing records are unique per
71
+ (Origin, world, user, client id), so two browsers signed in as the same GM on the same world hold two
72
+ independent records and neither re-pair disturbs the other.
73
+
74
+ - `id` is bounded to hexadecimal characters and dashes, 8 to 64 characters long.
75
+ - `label` is 1 to 64 characters of Unicode text. Whitespace-only values are rejected, as are control
76
+ (C0/C1), zero-width, bidirectional-override, and Unicode tag characters. The schema is the
77
+ enforcement point because any local process can send a pairing request and the label is later
78
+ printed by `auth list` and `auth pending`.
79
+ - Labels are not unique. Duplicate labels are accepted as they arrive, without suffixing.
80
+ - A label is chosen once, in the browser, at pairing time. No control operation renames a stored
81
+ record; re-pairing is the way to change a label.
82
+
83
+ The bridge hello carries `clientId` at the top level, beside `pairingId` and `credential`, because it
84
+ is authentication material rather than session content: the daemon rejects a hello whose client id
85
+ does not match the stored pairing. The label is not resent on hello — the daemon's pairing record owns
86
+ it, and the browser keeps a copy only to display it.
87
+
88
+ ## Daemon control
89
+
90
+ Authenticated local clients manage pairings and the active bridge through closed,
91
+ operation-discriminated control requests; responses repeat both the correlation id and the
92
+ operation. The operation registry lives in the protocol package. `auth.approve` takes only an
93
+ optional pairing `code`; the approved record's label comes from the pairing request itself.
94
+
95
+ `auth.await` is the long-poll behind the interactive pairing wait. It answers at once with the
96
+ earliest live pending request, in the same public shape `auth.pending` serializes and with no
97
+ credential material; when nothing is pending it parks the response until a request arrives, or until a
98
+ bounded daemon-side cap elapses and the result carries no request. Every parked waiter is answered by
99
+ the next arriving request, and a waiter is discarded when its client socket closes. The cap is the
100
+ invariant that keeps a parked response inside the caller's own request timeout; `timeoutMs` may ask
101
+ for a shorter park and is bounded by the cap, so no caller can park longer than the daemon allows.
102
+
103
+ `auth.prune` deletes idle pairing records. Its optional `olderThanDays` is a non-negative integer and
104
+ defaults to 30; a record is removed when its `lastSeenAt` is older than that many days before the
105
+ call. `lastSeenAt` is stamped when a pairing is approved, when its browser passes the bridge hello,
106
+ again when that bridge connection closes, and when a hello whose credential the daemon accepted is
107
+ rejected with `BRIDGE_BUSY`, so the cutoff measures how long a record has been idle rather than how
108
+ long ago it last connected. A hello rejected with `UNAUTHORIZED` proves nothing about the record and
109
+ never stamps it. The pairing that owns the active bridge, and the holder of a live
110
+ abnormal-disconnect lease, are excluded from removal regardless of their timestamps, so pruning can
111
+ never unpair the browser that is connected or the one the daemon is still holding a slot for. The
112
+ config is rewritten only when at least one record is removed. The result is `{ olderThanDays, pruned }`,
113
+ where `olderThanDays` is the cutoff the daemon applied — including the default when the caller omitted
114
+ it — and `pruned` carries the removed records in the same public, digest-free shape `auth.list`
115
+ serializes. The daemon computes the set at execution time; a caller that previewed candidates from
116
+ `auth.list` holds an advisory list, not the outcome, and the executed set may be wider than that
117
+ preview when a record crosses the cutoff between the two calls.
118
+
119
+ The active bridge itself may use
120
+ exactly one control operation: revoking its own pairing. A browser Unpair deletes its stored
121
+ credential only after a correlated successful revocation; on failure the credential is retained for
122
+ retry, and discarding it locally is a separate deliberate action.
123
+
124
+ ## Bridge sessions
125
+
126
+ After Foundry is ready, the module presents its pairing identity, client identifier, world, user,
127
+ versions, and the command set it can execute; the daemon forwards only commands advertised by the
128
+ active session.
129
+
130
+ - Authentication or protocol-version rejection is terminal for that module load, so a persistent
131
+ configuration problem does not become a reconnect loop.
132
+ - A session that completed its handshake and later loses transport reconnects with bounded
133
+ exponential backoff.
134
+ - `BRIDGE_BUSY` is terminal for that client instance but preserves the stored credential; the
135
+ operator releases the current owner and retries explicitly rather than pairing again.
136
+ - Only the exact active authenticated socket can release ownership with a goodbye.
137
+ - A same-pairing takeover immediately completes requests owned by the displaced socket with an
138
+ indeterminate-delivery error: the operation may already have committed, so callers inspect world
139
+ state before retrying.
140
+ - If the connected user loses GM authority, the bridge answers the pending command with a
141
+ correlated `PERMISSION_DENIED` without dispatching it, so the caller knows the rejected command
142
+ started no mutation.
143
+
144
+ ## Commands and correlation
145
+
146
+ Commands are explicit typed names registered in the protocol package; there is no generic RPC
147
+ method. A request carries a caller-chosen correlation id and schema-validated parameters, validated
148
+ in the CLI before connecting and again in the bridge before dispatch. The command must be both
149
+ registered and advertised by the active bridge. The response repeats the correlation id and is
150
+ exclusive: success carries a result, failure carries a structured error.
151
+
152
+ ## Result conventions
153
+
154
+ Document results live under a type-named key inside the result. Collection, action, and bulk
155
+ results use their documented keys, which vary between commands but are stable for each one.
156
+ Serialized projections expose `id` as the public identifier; a source `_id` mirror may accompany
157
+ it. A previewed new document has no persistent identity, and an id observed during a preview must
158
+ not be reused. List-like responses that paginate return their collection with a total and a
159
+ has-more flag, and filters apply before pagination.
160
+
161
+ ## Dry run
162
+
163
+ Mutation commands accept a dry-run request that passes through validation, resolution,
164
+ sanitization, permission checks, capability checks, and preparation, then returns before
165
+ persistence using the normal result shape with an explicit dry-run marker. Only values knowable
166
+ before execution are reported: random selection, rendering, hooks, and other execution-dependent
167
+ observations may be absent or explicitly unconfirmed. A successful preview reserves nothing — the
168
+ world can change between preview and commit.
169
+
170
+ ## Idempotency
171
+
172
+ Commands with duplicate-creation or non-repeatable-action risk accept or require an idempotency
173
+ key identifying one logical request. Reusing a key with a different command or payload is rejected
174
+ as `IDEMPOTENCY_KEY_CONFLICT`. Idempotency memory is runtime state: bounded, and cleared by daemon
175
+ restart, bridge replacement, world switch, expiry, or eviction. It reduces duplicate effects across
176
+ response loss; it is not a durable transaction, so an indeterminate delivery still ends with a
177
+ world-state read.
178
+
179
+ ## Batch requests and bulk writes
180
+
181
+ Connection reuse (`exec --stdin`) is a client mechanism: each request keeps its own correlation id
182
+ and success state, and failures are reported per request. Bulk write commands are ordinary typed
183
+ commands over bounded arrays: elements are prevalidated, but Foundry persistence is not
184
+ transactional, so the result reports overall completeness plus a per-element outcome, and every
185
+ outcome carries its own meaning.
186
+
187
+ ## Error model
188
+
189
+ Errors have a stable code, a human-readable message, and optional structured details. The code and
190
+ documented detail fields are the stable contract for branching; message text is not. The exhaustive
191
+ code set is exported by the protocol package. The classes consumers act on:
192
+
193
+ | Class | Representative codes | Consumer response |
194
+ |---|---|---|
195
+ | Request/schema | `INVALID_PARAMS`, `UNKNOWN_COMMAND` | Correct the request or resolve version skew |
196
+ | Authentication/permission | `UNAUTHORIZED`, `PERMISSION_DENIED` | Restore credentials or authority |
197
+ | Pairing | `PAIRING_REQUIRED`, `PAIRING_EXPIRED`, `BRIDGE_BUSY` | Pair, retry revocation, or release the active owner as indicated |
198
+ | Lookup | `*_NOT_FOUND` | Refresh ids and world state |
199
+ | Safety/policy | `DELETE_FORBIDDEN`, `PATH_NOT_ALLOWED` | Change the requested operation |
200
+ | Capability | `UNSUPPORTED_OPERATION` | Choose a supported workflow or runtime |
201
+ | Size/resource | `PAYLOAD_TOO_LARGE`, `QUERY_TOO_BROAD` | Reduce or page the request |
202
+ | Bridge state | `BRIDGE_NOT_READY`, `BRIDGE_TIMEOUT`, `BRIDGE_DISCONNECTED` | Apply the delivery rules below |
203
+ | Unexpected | `INTERNAL_ERROR` | Preserve details and investigate |
204
+
205
+ Foundry DataModel validation failures surface as parameter errors and are distinguished in details
206
+ where available. A failed nested lookup identifies the level that failed.
207
+
208
+ With JSON output, a failed command emits one structured error envelope on stdout and exits
209
+ non-zero. The exit code is a coarse process classification; the structured error code is the
210
+ authoritative automation signal. `exec --stdin` reports per-line errors and uses its own aggregate
211
+ exit status.
212
+
213
+ ## Delivery states and retries
214
+
215
+ Retry safety is a function of whether the request reached Foundry:
216
+
217
+ | Condition | Forwarded to Foundry? | Retry meaning |
218
+ |---|---:|---|
219
+ | Client could not connect | No | Safe to retry after restoring the daemon |
220
+ | `BRIDGE_NOT_READY` | No | Safe to retry after a bridge connects |
221
+ | Response timeout after send | Possibly | May have committed; inspect state or reuse the same idempotency key |
222
+ | `BRIDGE_TIMEOUT` | Yes | May have committed; inspect state or reuse the same idempotency key |
223
+ | `BRIDGE_DISCONNECTED` | Yes or in flight | May have committed; inspect state |
224
+ | Structured command rejection | Resolved with an error | Correct according to the code |
225
+
226
+ The distinction between connection-phase and response-wait failures is carried in structured error
227
+ details. Default waits, forward timeouts, heartbeats, and backoff bounds are defined in the
228
+ protocol and CLI constants; runtime flags can override the client and daemon request timeouts.
229
+
230
+ ## Compatibility rules
231
+
232
+ - Additive result and handshake fields are preferred for compatible evolution.
233
+ - Request schemas remain explicit and versioned.
234
+ - A bridge advertises the command set it can execute; the daemon forwards only advertised commands.
235
+ - Unsupported version-dependent behavior produces a predictable error rather than a false success.
236
+
237
+ Foundry-version behavior belongs in [Foundry compatibility](compatibility.md), while per-command
238
+ request shape remains discoverable from the registry.
@@ -0,0 +1,233 @@
1
+ # Security
2
+
3
+ fvtt-world-cli is a local administration tool. It gives an authenticated caller access to the
4
+ authority of the connected Foundry GM client, within the typed command surface and explicit file
5
+ boundary described here.
6
+
7
+ ## Trust boundary
8
+
9
+ A request travels from the local CLI through the authenticated loopback daemon to the authenticated
10
+ Foundry GM bridge, which executes it through Foundry's APIs. The CLI input is untrusted. The daemon
11
+ authenticates and routes requests but does not grant Foundry permissions. The bridge validates
12
+ requests again and executes them as the connected Foundry user.
13
+
14
+ The design assumes a trusted local machine and a trusted GM-controlled Foundry session. It is not a
15
+ public multi-tenant API.
16
+
17
+ ## Authentication
18
+
19
+ - The daemon hard-rejects non-loopback listen hosts and validates the exact HTTP Host.
20
+ - A random device-local credential authenticates CLI and future Companion clients with a first-message
21
+ `client.hello`; it is stored only in the per-user config file.
22
+ - Every first frame and subsequent transport message is checked against a closed protocol schema
23
+ before role assignment or dispatch. Malformed first frames are closed immediately and cannot keep
24
+ an unauthenticated heartbeat connection alive.
25
+ - Each browser/world/GM profile has an independent bridge credential. Only its SHA-256 digest is kept
26
+ by the daemon; the clear credential stays in client-scoped Foundry storage.
27
+ - A pairing profile is identified by Origin, world, GM, and a per-browser client identifier that the
28
+ browser generates once and keeps in the same client-scoped storage as its credential. The identifier
29
+ is self-asserted and grants nothing: it only scopes which stored record a re-pair replaces, so one
30
+ browser re-pairing cannot rotate another browser's credential out from under it. Access remains
31
+ gated by human approval of the pairing code and by the credential-digest check on every hello, and a
32
+ hello whose client identifier does not match its pairing record is rejected as `UNAUTHORIZED`. That
33
+ storage is shared by every tab of one browser, so a first-use identifier can be generated twice when
34
+ two tabs start pairing at once; the browser discards an approved credential whose identifier no longer
35
+ matches the stored one instead of keeping a pairing the hello gate would refuse.
36
+ - The browser label attached to a pairing is constrained by the protocol schema — 1 to 64 characters,
37
+ with whitespace-only values rejected along with control, zero-width, bidirectional-override, and
38
+ Unicode tag characters — and a violating request is refused rather than repaired. Rejecting a
39
+ whitespace-only label removes the simplest way to send a label that reads as nothing; it is not a
40
+ guarantee that a label renders visibly, because blank-rendering filler characters pass the pattern.
41
+ The client identifier printed beside the label is what reliably distinguishes two browsers, including
42
+ two that share one label. The pairing socket is unauthenticated, so any local process can send a
43
+ pairing request, and an unfiltered label would reach a terminal as an escape-sequence injection or as
44
+ hidden text in agent output: both approval prompts, the one `auth approve` shows and the one the
45
+ `auth` pairing wait shows, print it as raw text, and JSON output escapes C0 control characters but
46
+ not C1, zero-width, bidirectional-override, or tag ones. The constraint covers the label field only;
47
+ the identity fields printed beside it are described under Known risks.
48
+ - The module removes those same characters from the label it collects in the Authorization window, and
49
+ also normalizes it to NFC, trims it, and caps its length, before the pairing request goes out. That
50
+ sanitizer is a usability layer, not the boundary: it keeps an honest browser from being refused over
51
+ a stray paste, while the schema check in the daemon remains the enforcement point, so a request from
52
+ any other local process is still refused rather than repaired.
53
+ - Browser pairing and bridge sockets require a syntactically valid HTTP(S) Origin, matched exactly
54
+ after pairing. Credentials never appear in URLs, command lines, environment variables, or output.
55
+ - The bridge starts only for a configured GM client.
56
+ - Another pairing cannot displace the active bridge. A same-pairing reconnect may take over after a
57
+ reload; requests already forwarded to the displaced socket fail immediately as indeterminate.
58
+ Abnormal close retains a 30-second lease against other pairings, while intentional goodbye,
59
+ release, and revocation do not create a lease. Release uses a dedicated terminal close code so the
60
+ released browser cannot automatically reclaim the slot.
61
+ - Only the exact active authenticated bridge socket can send `bridge.goodbye` or revoke its own
62
+ pairing. A socket is not assigned the bridge role or pairing identity until the complete handshake
63
+ has authenticated.
64
+ - Browser Unpair deletes its clear credential only after a correlated successful revocation response.
65
+ On failure it retains the credential for retry; Forget local is a separate action that deliberately
66
+ leaves the daemon-side profile active.
67
+ - Protocol-version or authentication rejection stops that bridge load until the Foundry client is
68
+ reloaded. `BRIDGE_BUSY` is reported separately, preserves the credential, and permits manual Retry
69
+ after the current owner is released.
70
+ - A bridge that loses live GM authority returns correlated `PERMISSION_DENIED` before it closes and
71
+ does not route the denied command, preserving a determinate no-mutation outcome.
72
+
73
+ Loopback reduces exposure but does not replace authentication. Other processes running as the same
74
+ local user may be able to read local configuration or connect to local ports. The daemon accepts only
75
+ the exact normalized Host authority configured for its listener; post-bind publication of a numeric
76
+ runtime address does not broaden that authority or invalidate an explicitly configured `localhost`.
77
+
78
+ ## Command boundary
79
+
80
+ The bridge accepts only explicit commands registered in the protocol and advertised during the
81
+ handshake. Each command has a schema and a known handler. There is no generic method invocation or
82
+ universal “edit anything” endpoint.
83
+
84
+ Mutations use Foundry Document APIs or a reviewed typed Foundry action. Raw live world databases are
85
+ outside the command surface.
86
+
87
+ ## Validation and protected metadata
88
+
89
+ Closed document families enumerate writable fields. Open families allow Foundry-, system-, or
90
+ module-defined data but pass every write and preview through shared protected-metadata sanitization.
91
+
92
+ Document identity, statistics, authorship, and raw ownership are server-controlled. Ordinary content
93
+ payloads cannot set them. Supported ownership changes use dedicated commands with GM and schema
94
+ checks.
95
+
96
+ Validation occurs in the CLI and again in the bridge. Foundry performs its own DataModel and
97
+ permission validation before persistence.
98
+
99
+ ## Permissions and destructive actions
100
+
101
+ The bridge requires a GM session for mutations. Delete commands can require explicit force where the
102
+ document has high-risk references or consequences. A force flag acknowledges the command's defined
103
+ guard; it does not bypass Foundry permissions, module hooks, validation, or filesystem containment.
104
+
105
+ Foundry hooks can veto or partially apply writes. Mutation handlers confirm observable stored state
106
+ where the command contract requires it and return structured partial or failed outcomes instead of
107
+ claiming success.
108
+
109
+ ## Document ownership
110
+
111
+ Ownership is access policy rather than ordinary document content. Raw `ownership` is excluded from
112
+ normal create and update payloads. Dedicated `<family>.ownership.set` commands change the default or
113
+ per-user level on supported families.
114
+
115
+ Read projections expose ownership only where the public command contract intentionally includes it.
116
+ Embedded documents often derive access from a parent and do not provide an independent ownership
117
+ surface. What each family exposes is described by its own schema and result, not by another
118
+ family's behavior.
119
+
120
+ ## Executable content
121
+
122
+ CLI-supplied data cannot create an arbitrary JavaScript execution path through the bridge.
123
+
124
+ - Macro bodies and chat content can be stored but are not executed or routed through command
125
+ processors.
126
+ - Action commands invoke only their fixed typed Foundry methods.
127
+ - Writes that supply core script- or macro-executing RegionBehavior types are rejected through a
128
+ shared guard.
129
+
130
+ Installed systems and modules remain trusted Foundry code. They can register behavior types, hooks,
131
+ ActiveEffect interpretations, or other data-driven features whose effects the bridge cannot classify
132
+ universally. Operators must review system/module semantics when authoring content those extensions
133
+ interpret.
134
+
135
+ A typed Foundry action can also trigger existing GM-authored automation. Combat changes, table draws,
136
+ card actions, document hooks, region triggers, and game-system workflows can cause secondary writes
137
+ or chat output after the direct command.
138
+
139
+ ## Settings
140
+
141
+ Setting discovery is read-only. Listing returns registration metadata, while reading a value requires
142
+ an explicit namespace and key. Values are serialized with bounded depth, node count, and byte size.
143
+
144
+ The CLI does not expose setting writes because settings can alter global security and runtime
145
+ behavior and frequently invoke module callbacks.
146
+
147
+ ## Search
148
+
149
+ `world.search` indexes selected authored world and optional compendium content in the connected GM
150
+ client. Search results can reveal content visible to that GM, including text not visible to ordinary
151
+ players.
152
+
153
+ Queries and responses are bounded. Search indexes are runtime caches and can be stale until their
154
+ invalidation or rebuild completes. Search is a discovery surface, not an authorization boundary.
155
+
156
+ ## File write boundary
157
+
158
+ File commands operate only through Foundry's managed `data` source.
159
+
160
+ Reads address normalized managed-data paths. Writes are restricted to the active world's
161
+ `worlds/<worldId>/` tree and exclude:
162
+
163
+ - `world.json`;
164
+ - `data/` and all descendants;
165
+ - `packs/` and all descendants.
166
+
167
+ Containment and exclusions are segment-aware and are checked before payload decoding or capability
168
+ dispatch. Absolute host paths, traversal, sibling-prefix tricks, and encoded attempts to cross the
169
+ boundary are rejected.
170
+
171
+ The module accepts upload content over the authenticated local transport and a managed data-relative
172
+ destination. It does not read arbitrary files from the operator machine. The CLI may read an explicit
173
+ local source file supplied by the operator, then sends its bytes as data.
174
+
175
+ File mutation does not rewrite document references. Reference changes require an explicit document
176
+ command so their intent and permissions remain visible.
177
+
178
+ ## Compendium imports
179
+
180
+ Compendium commands are read-only with respect to packs. Supported import commands create a new world
181
+ document from a pack entry using a closed, family-compatible override schema.
182
+
183
+ Imports preserve legitimate authored source data after normalization. Installed system or module
184
+ content can still carry data-driven behavior; importing is not a security audit of the source pack.
185
+ The bridge does not expose arbitrary compendium writes.
186
+
187
+ ## Availability and resource limits
188
+
189
+ The daemon and module bound uploads, WebSocket frames, search work, batch sizes, and selected result
190
+ shapes. Oversized operations return structured errors where possible without dropping the shared
191
+ bridge session.
192
+
193
+ The system remains susceptible to ordinary local denial of service by an authorized caller issuing
194
+ many expensive Foundry operations. It is designed for cooperative local automation, not hostile
195
+ multi-user scheduling.
196
+
197
+ ## Known risks
198
+
199
+ - A stolen device credential lets a local process use the active bridge; a stolen bridge credential
200
+ lets a matching Origin/world/user runtime authenticate that pairing.
201
+ - Another OS user able to reach loopback can submit a pairing request with a forged Origin, client
202
+ identifier, and label. Approval therefore trusts that the displayed pending request came from the
203
+ operator's own browser.
204
+ - The label and the client identifier are the only pairing identity fields constrained against terminal
205
+ escape sequences. World title and GM name arrive on the same unauthenticated pairing request without
206
+ that constraint, and the CLI prints them as raw text wherever it renders a pending request or a
207
+ stored profile — both approval prompts and the `auth prune` candidate listing, which precedes an
208
+ irreversible deletion. A local process can therefore redraw the identity lines an operator reads
209
+ before answering, and a title that survived one approval is re-rendered from the stored record every
210
+ time a later command lists it.
211
+ - `auth approve` with no code refuses while several requests are pending, and the `auth` pairing wait
212
+ instead renders the earliest one and asks about it. A request a local process forged before the
213
+ operator clicked Pair in the browser is therefore the one the wait offers, which is a reason to read
214
+ the rendered identity rather than answer the prompt by reflex. Both commands approve only the request
215
+ whose identity they displayed.
216
+ - A compromised GM browser session or installed Foundry module is already inside the trusted runtime.
217
+ - Foundry, systems, and modules can attach hooks and side effects to otherwise ordinary writes.
218
+ - Timeouts and disconnects can leave delivery indeterminate; a mutation may have committed.
219
+ - Native Foundry batch operations are not transactional and can partially apply.
220
+ - Search and read commands can expose GM-visible world content to the local caller.
221
+ - Large but permitted content can consume browser memory and processing time.
222
+ - Declarative content can reference existing executable or module-interpreted content.
223
+
224
+ ## Operator guidance
225
+
226
+ - Keep the daemon on loopback, protect the per-user config, and review Origin/world/GM before approval.
227
+ - Confirm the connected world and GM identity before mutation.
228
+ - Use JSON output, dry runs, stable idempotency keys, and post-write reads for automation.
229
+ - Review every outcome of a bulk operation.
230
+ - Treat forwarded timeouts and disconnects as potentially committed.
231
+ - Review installed systems and modules before authoring automation-sensitive data.
232
+ - Back up important worlds before large migrations.
233
+ - Run the live smoke workflow only in a designated test world.