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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/fvtt-world-cli.js +47 -0
- package/bin/prepare-package-assets.mjs +22 -0
- package/bin/sync-installed-skill.mjs +89 -0
- package/dist/index.js +16200 -0
- package/docs/README.md +71 -0
- package/docs/architecture.md +216 -0
- package/docs/commands.md +367 -0
- package/docs/compatibility.md +26 -0
- package/docs/getting-started.md +104 -0
- package/docs/images/authorization-window.png +0 -0
- package/docs/images/icon.svg +16 -0
- package/docs/images/mark.svg +15 -0
- package/docs/images/module-settings.png +0 -0
- package/docs/protocol.md +238 -0
- package/docs/security.md +233 -0
- package/docs/skill.md +65 -0
- package/package.json +50 -0
- package/skills/foundry-world-editor/SKILL.md +156 -0
- package/skills/foundry-world-editor/SKILL.md.sha256 +1 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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.
|
|
Binary file
|
|
@@ -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
|
package/docs/protocol.md
ADDED
|
@@ -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.
|
package/docs/security.md
ADDED
|
@@ -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.
|