Skip to content

v0.2.0

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 16 Sep 18:10
· 70 commits to main since this release

Closes a hole where a bootstrap token was a bearer capability for the space it
named. Upgrade before putting any transport in front of this package.

A bootstrap page was served on the token's own authority

ViewSyncService::bootstrap() never validated the context, unlike
openBootstrap() and delta(). The token carries the space it reads, so the
caller's session was never consulted.

Two tenants that share a view definition — the normal case, since the space is a
separate axis and does not enter the filter signature — could read each other's
data by forwarding the token. An epoch rotation, the one tool for forcing every
client to reset, could not revoke a token already issued.

bootstrap() now takes the context the caller expects, the way delta() already
carries one in its cursor, and refuses any page whose context does not match.
Space, schema version and epoch all live inside the fingerprint, so one
comparison closes all three.

Breaking: the signature is now
bootstrap(CursorContext $context, ViewDefinition $view, BootstrapToken $token).

What a transport must bind

docs/security/threat-model.md now states what the engine cannot check. A
replica id arrives from the client and selects an acknowledgement stream, so
anyone who names another device's replica claims its sequence numbers — that
device's next push then fails terminally and its queued mutations are
unrecoverable. The same applies to client-chosen mutation ids. Bind both to a
stable account identifier, never a session id: the engine compares the stored
actor on replay, so an identifier that rotates at re-login turns every legitimate
retry into a protocol error.

It also states plainly that a view filters rows and not columns, and that each
field's origin names the actor who wrote it — so a record serialized without an
explicit field whitelist discloses both values and authorship.

Fixed

PdoStore resolves its PDO handle per call through an overridable
connection() rather than capturing it. A host whose framework reconnects
underneath a long-lived store would otherwise open the transaction on the new
connection while the writes went to the dead one, with the rollback rolling back
nothing — partial persistence, no error raised.

Verification

91 tests against the in-memory store, a store that shares no objects across
commits, and SQLite, on PHP 8.4 and 8.5; MySQL and PostgreSQL in CI; plus the
deterministic simulator and the concurrent-writer experiment.