Skip to content

v4.1.2

Choose a tag to compare

@github-actions github-actions released this 06 Oct 12:07
· 1544 commits to main since this release

"Reliable Bridge" (LOG #97–#98): the Reality Bridge of 4.1.1 after an outside review relayed by the maintainer and
checked against the code (three of its four P0s real, the fourth shown not to apply), and the first release of the
plan towards a professional engine for a small studio: nothing added to the content, the commands or the scripts;
what 4.1.1 promised made true under concurrency, across devices, through a rotation and under load, then measured
by mutation. Nothing to change in a game; a 4.1.1 Bridge keeps its config.json and its journal.

Fixes

  • Bridge: one proposal at a time per player. In 4.1.1 propose awaited the Biscuit check and the signature
    between reading a player's last sequence and writing the journal, so concurrent proposals could all take the same
    sequence, and two with one dedupeKey were both accepted; the player's client then skipped the others as already
    seen, and a signal was lost. The deduplication, the quotas, the sequence, the signature and the journal line now
    run under a lock per player (bridge/src/lock.ts), where the player and the token are checked again: a proposal
    that overlaps a revocation is refused. A pairing code confirmed twice at once is confirmed once (409 for the other).
    Found by an outside review of 4.1.1 (the first of its four P0s), reproduced in tests/bridge.test.ts.
  • A save is bound to its player. In 4.1.1 the client acknowledged a loaded save's cursor with whatever link the
    device held, so a save imported from another device (or saved before an unlink and a new pairing) could
    acknowledge, for the new player, signals that save never applied, or loop on a refused acknowledgement. The first
    signal now writes the Bridge's pseudonymous player id into the save (GameState.reality.playerId, the field 4.1.1
    declared and never filled; a session entry carries it too, so a replay is exact); a signal for another player is
    mismatch: nothing applied, nothing acknowledged, the link idles with that status and the pause menu offers to
    use this device's link with that save (its link state starts over, cursor 0). The outside review's second P0.
    docs/en/REALITY.md, two interface texts (realityMismatch, realityRelink).
  • A rotation no longer strands a player. In 4.1.1 the journal kept the signature only, nothing re-signed, and a
    link that was open kept its keyring, so a signal signed by a key past its window was refused for good and the
    cursor stuck (the outside review's fourth P0). The journal now keeps the payload as accepted and the Bridge signs
    what waits again under its current key at delivery (JournalEntry.payload, kid); a client asks for the keys once
    when a signal names one it does not know (RealityClientOptions.refreshKeys, wired in the player). Key windows
    and expiries carry five minutes of clock tolerance (CLOCK_SKEW_MS, the same in the Rust cross-check; the
    conformance corpus has a case on each side, 22 → 25). GET /v1/signals answers { signals, sequences } and the
    poll transport follows the sequences instead of counting. Event streams: at most 4 per player (429 beyond), closed
    by the Bridge when the link is revoked or expires or when the reader stops reading (64 KB unsent); a signal
    accepted while a stream opens is held and sent after the backlog (the review's third P0 could not happen: the
    backlog and the subscription were one synchronous tick; the stream now registers first, and a test proposes while
    it opens). docs/en/REALITY-OPS.md "Keys and rotation" rewritten.
  • The journal survives a crash and is read, never matched. A last line cut short by a crash (no newline, not
    JSON) stopped the Bridge from starting; it is dropped and said in the log (journal.repaired), while any other
    line that does not parse is still refused as corruption, with its number. Forgetting a player rewrote the file
    by matching its id as text, which took another player's line whose dedupeKey was that id; each line is now read
    as an event. The rewrite fsyncs the directory after the rename where the system allows. Two commands:
    web-scumm-bridge doctor (what the journal holds, torn or corrupt) and web-scumm-bridge compact [--retention-days=90] (pairings past their time, earlier versions of a player's line and signals acknowledged and
    older than the retention dropped; a player's last signal and everything unacknowledged kept). The review's P1s on
    the store.
  • The Bridge's surface, bounded. Codes waiting for a confirmation live in memory only (1000 at most, swept),
    where 4.1.1 appended and fsync'd a journal line for every anonymous request. Per address: 60 requests a minute on
    the routes anyone may call, and 60 failed authentications a minute before every request of that address is
    refused for a while (Retry-After); serve --trust-proxy reads X-Forwarded-For. A proposal's token is checked
    before any player is named (a 401 or 403 before any 404; the manifest, public, still answers 422 first). A capability lives 180 days at most from its
    pairing, renewals included. POST /v1/unlink (Bearer capability): the pause menu's "Unlink" revokes the link on
    the Bridge, not only on the device, and a reconnection planned before it no longer brings the old link back. The
    operator's token is compared in constant time. init writes the Biscuit root's private half to root.key, read by
    grant only (serve never loads it; a 4.1.1 config.json still works); the demonstration webhooks' tokens live 30
    days (--demo-days), and init --no-demo-webhooks leaves them out for a Bridge on the Internet. The review's
    remaining P1s; its key-pinning ask is answered in docs/dev/THREAT-MODEL.md instead (the signing key lives on the
    Bridge: a pin would not survive its compromise, and TLS already names the host).
  • A required signal's fallback is proved, not only named. validate checked that a fallback matched a rule;
    the content lint now checks that the closed witness plays it, and says fallback-unplayed (a warning) when the
    game finishes without the world outside by another route: the declared rule is not the way through, and may be
    dead. The review's P1 on the fallback proof, as a lint rather than a validation error: the closed proof still
    holds, the declaration is what is wrong.
  • What a signal rests on is falsified like what a save rests on. Mutation testing gains a reality set
    (npm run test:mutation:core -- --set=reality: the engine's receive, the protocol, the client, the Bridge, its
    store, its lock and its policy, judged by the Reality and Bridge tests) and a gate by identity: every surviving
    mutant is named in docs/dev/mutants.json (file, operator, from, to, why), and a survivor not named there fails
    the run in every mode, where 4.1.1 compared a count. Measured on the reality set: 382 of 484 killed, 14
    equivalents named, 88 survivors listed as missing tests in docs/dev/MUTANTS.md (the nightly runs the set
    without gating until they are killed or named). The corpus gains seven cases (the exact edges of the clock
    tolerance, a header that is a list or null, a payload that is JSON but not an object), and one found a divergence:
    a list header was algorithm in JavaScript and header in Rust; it is header in both. Coverage includes
    bridge/src with floors. Properties at
    random (fast-check) on concurrent proposals: sequences 1..n and one acceptance per dedupeKey whatever the
    interleaving; a write that fails (disk full) leaves nothing behind and the next proposal takes the same sequence.
    The package web-scumm-bridge is now one JavaScript module (esbuild) plus its Datalog policies: Node 22,
    Biscuit's WebAssembly and zod, no tsx, no TypeScript at run time; fresh-install installs it from its tarball
    outside the repository, runs init --no-demo-webhooks and doctor, starts it and reads its keys; the release
    carries the Bridge's own SBOM and attests every file it uploads. The Studio's write routes accept a Host that
    names this machine (localhost, 127.0.0.1, [::1], or the LAN address dev:lan serves) and nothing else, so a
    page that rebinds a name to the Studio's port finds the door closed. npm run seal no longer prints the password.

Manual passes (D12: reported, not blocking)

11 of 16 done.

Pass Status Who, when Device, OS, browser, versions What failed
1 two proposals for one player can share a sequence, or a dedupeKey be accepted twice one at a time per player under a lock; 100 concurrent proposals, 50 with one key, a proposal overlapping a revocation, a code confirmed twice, random interleavings (tests/bridge.test.ts)
2 a save's cursor can be acknowledged for a player that never applied those signals the save is bound to its player on the first signal; a save under another link is mismatch, nothing applied or acknowledged; relinked on request (tests/reality-engine.test.ts)
3 a rotation can strand a player what waits is signed again under the current key; a client asks for the keys once; five minutes of clock tolerance, the same in Rust (tests/bridge.test.ts rotation, the conformance corpus, 32 cases)
4 a stream can outlive a revoked or expired link, or a reader that stopped closed by the Bridge on revocation, expiry and 64 KB unsent; four per player (tests/bridge.test.ts)
5 anyone can make the journal grow, or a bad token learn which players exist codes in memory, 1000 at most; 60 anonymous requests and 60 failed authentications per address and minute; the token before any player (tests/bridge.test.ts)
6 a torn journal line keeps the Bridge from starting, or a deletion takes another player's line the last fragment dropped and reported, any other bad line refused with its number; deletion by reading each event (tests/bridge.test.ts)
7 a required signal's fallback is a rule nobody plays fallback-unplayed from the closed witness (tests/reality-proof.test.ts)
8 the Bridge's package needs TypeScript at run time, or was never installed outside the repository one JavaScript module; fresh-install installs it from its tarball, runs init, doctor and serve, reads its keys (CI)
9 the tests of what a signal rests on are not measured mutation set reality: 382 of 484 killed, 14 equivalents named, 88 missing tests listed (docs/dev/MUTANTS.md), the nightly runs it
10 a page can rebind its name to the Studio's port and write the Studio answers its own names only (tests/studio-security.test.ts)
Pass Status Who, when Device, OS, browser, versions What failed
A Bridge deployed behind HTTPS with --trust-proxy, a game on a phone linked to it, a rotation while it is open not done
A real webhook or email connector sending a signal not done
The seven field passes (docs/en/FIELD.md) not done
The 88 surviving mutants of the reality set killed or named not done
Signed tag (git tag -s) not done