Ship the document channel, the signed grant, and a fail-closed authorized? - #83
Open
jpcamara wants to merge 30 commits into
Open
Ship the document channel, the signed grant, and a fail-closed authorized?#83jpcamara wants to merge 30 commits into
jpcamara wants to merge 30 commits into
Conversation
jpcamara
force-pushed
the
feat/authorized-documents
branch
from
September 4, 2026 20:13
eff4ee2 to
dabdba1
Compare
sync_subscribed now asks authorized?(key) before opening a stream or serving
state, and the concern's default answer is no: a channel authorizes
subscribers by decision, not by omission. Channels override the method —
plain Rails idiom, same word lexxy-realtime uses:
def authorized?(key)
current_user&.can_edit?(key)
end
When the rejection came from the default (no override anywhere), the log
says how to fix it; when an app's own authorized? said no, it just logs the
reject. The install template already generated the method — it now leans on
sync_subscribed for the reject instead of doing its own. README channel
examples all carry the method; the ephemeral scratchpad ones return true
with the reason stated.
The token flow authorized? wants for record-backed documents, provided by the gem: a page mints record.collaborative_sgid(:body) — a signed GlobalID scoped to that attribute — and the channel trades it back for the record with Y::Collaborative.locate(sgid, :body). A token minted for one attribute verifies only under that attribute's purpose (yrby/<name>), so it cannot open another field or channel; tampered, expired, and wrong-purpose tokens locate nothing, as does a destroyed record. The engine includes the module into ActiveRecord::Base. globalid becomes a declared dependency: Rails apps get it through activejob, but activerecord alone does not.
The example subscribed with a bare params[:id] and stored under it, which answered none of the questions a reader actually has: what a key is, where it comes from, and why the channel may load and record under it. The key is now visibly derived from a record (post/42/body), the hook comment says the hooks are that document's storage, and authorized? checks the post the key names.
The gem now carries the whole wire side of a record-backed document, the way Turbo carries Turbo::StreamsChannel. collaborative_document_tag renders a mount element with a signed, attribute-scoped grant; Y::DocumentChannel verifies the grant on subscribe, derives the document from the record it locates, and stores through Y::Document. Clients never name documents, and there is no channel to generate — yrby:install lands only the migration. Channels that declare no on_load/on_change now default to Y::Document storage (the Action Text posture: the gem's tables are the default, the hooks are the seam for pointing storage elsewhere). Outside a yrby-rails app the concern still fails closed until hooks are declared. The README leads with the tag, the client connection, and reading the document back from the database; the hand-written channel moves to the document-channel section as the custom path.
has_collaborative_document :name, encrypted: true records which storage class backs an attribute, and Y::DocumentChannel follows it: the binding, every load, and every append for a declared-encrypted attribute go through Y::EncryptedDocument, so the bytes are ciphertext at rest and unreadable through the plain classes. Undeclared attributes keep plain Y::Document. The declaration lives on the model because encryption is a property of the attribute's storage — nothing a page renders or a client sends can influence which class the server reads and writes.
collaborative_document_tag now renders <yrby-document grant name>, and yrby-client gains the element behind it (import "yrby-client/element") — the way <turbo-cable-stream-source> sits behind turbo_stream_from. The element subscribes itself to Y::DocumentChannel with the tag's signed grant, keeps its Y.Doc across DOM moves and Turbo restores, and hands the synced document to app code through its doc property and a bubbling yrby:synced event; connection needs no per-feature JavaScript. All elements on a page share one consumer, created from @rails/actioncable (a new optional peer dependency, imported only when used) or assigned once via YrbyDocumentElement.consumer for AnyCable. The element module loads in Node too (SSR, tests): the base class falls back to a plain class and only registration is browser-gated.
Every sentence outside a code block is rewritten to read the way a person explains things, with the same facts. Code blocks, headings, links, and the editors table are unchanged. One stale claim is corrected: on_load and on_change now default to Y::Document storage instead of being required.
Comments, and the reject log line that tells a channel to define authorized?.
jpcamara
force-pushed
the
feat/authorized-documents
branch
from
September 5, 2026 03:07
dabdba1 to
b46dde3
Compare
The policy ran on every incoming frame, which put a record load and the application's own queries on the path of every keystroke and, on plain Action Cable, every cursor move. Action Cable's model is that a confirmed subscription is the grant, so the policy now runs once, at subscribe. The decision is carried for the life of the subscription on both transports: Action Cable keeps the channel instance, and AnyCable round-trips it as declared channel state, which lives in anycable-go rather than the browser. A frame with no authorized subscription behind it is refused even when it carries a valid grant. sync_subscribed now returns true on success. It returned whatever the pending-gap check evaluated to, which is nil in the common case, so a caller could not tell an accepted subscription from a refused one. The tradeoff is that a permission revoked mid-session reaches an open subscription when that client next subscribes. Short-lived grants bound it, and an application that must cut access immediately stops the subscription itself. The tests and docs now state that contract.
The revocation scenario asserted that an open session blocks when permission is withdrawn mid-edit. It now covers the contract the channel actually has: the open subscription keeps working, the next one is refused, an authorized peer is unaffected, and restoring permission admits the user again on resubscribe. This suite is not run by CI, so it stayed green while contradicting the server.
The suite covers Turbo restores and previews, the async consumer import, AnyCable, and subscribe-time authorization against a real Rails server in real Chrome. Nothing ran it, so it went stale against the server without anyone noticing. It asked agent-browser for a session id with a subcommand and flags that version does not have. Unrecognized arguments fall through to "report the current session name", so the suite ran under whatever session happened to be current, usually the shared "default" one. It names its own sessions now, per process, so parallel worktrees do not collide. agent-browser becomes a devDependency of the client package, the way the demo already declares it, so CI resolves it from node_modules rather than the PATH. Also aligns the record example in Y::Collaborative's docstring and the README with the shipped channel: params[:grant], located per call rather than memoized, because AnyCable builds a fresh channel per command.
Two commands the suite relied on are broken in agent-browser 0.28. Role lookup misses links and headings, so 'find role link --name Away' matched nothing even though the link is plainly there; buttons resolve fine, which is why this went unnoticed. And 'wait --url' hangs and returns nothing even when the URL already matches. Locating the link by its visible text and checking location through the --fn wait the suite already uses covers the same ground. The suite passes end to end again, twice in a row, 44 checks.
json 3.0 changed JSON.parse's signature, and ActiveSupport 8.1's JSON.decode still calls it with two arguments. Action Cable decodes every WebSocket frame through that method, so the demo server raised ArgumentError on each one and the gem's signed-message tests failed the same way. Neither Gemfile.lock is committed, so CI resolves fresh and took json 3.0 the moment it shipped; it would break any build today, not just this branch. Naming json here is what lets bundler hold it at 2.x. Development and CI only. The incompatibility is between two dependencies, not something the published gems should constrain for an application. Rails already fixed it on 8-1-stable (rails/rails#58601); drop the pin once a release that includes it is out.
jpcamara
force-pushed
the
feat/authorized-documents
branch
from
September 8, 2026 01:32
a54fdc5 to
c851138
Compare
Comments, the changelog, the READMEs, and CONTRIBUTING were dense and read as machine-written: stacked clauses, coined labels, and the same Turbo comparison repeated across files. They now say one thing per sentence. Also fixes prose that no longer matched the code. The channel docstring and a changelog entry still described per-message authorization, the changelog example used params[:sgid], and CONTRIBUTING said agent-browser had to be on PATH when npm ci now installs it.
The --channel template re-checked authorized? on every receive. The shipped Y::DocumentChannel and the Y::ActionCable concern both authorize once, when the client subscribes, and the subscription is the grant from then on. The generated channel now follows the same model: receive calls sync_receive directly, and the comment on authorized? says when it runs.
Subscribing needs two things to be true: the grant resolves to a record, and the application's policy allows this user to have it. Both lived in one method named document_authorized?, next to an authorized? that read like a third check but could never fail. It returned record.present?, and the record was already known to be present by the time it ran. subscribed now calls locate_record and policy_allows? by name, and authorized? returns true with a comment explaining why it exists: the concern's default refuses every subscription, so the channel has to answer it. It also documents the constraint that forces this shape. sync_subscribed takes the document key, and asking a built-in attribute for its key creates the document row. The policy has to run before that, or a denied subscription leaves a row behind. The existing test asserting no row is created on denial is what pins it, and it now says so. The README explains which hook to use for which channel, and says not to override authorized? on a subclass of the shipped channel. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
The shipped channel checked the application's policy in its own method before sync_subscribed, and then answered the concern's authorized? with a constant. It had to: Attribute#key called Y::Document.for, which creates the row, and a denied subscription must not create one. So the policy could not wait for the concern's hook. Attribute#key now looks the binding up without creating it, falling back to the conventional key. With that, the policy moves into authorized?, where every channel on Y::ActionCable has it, and the extra method, the constant, and the ordering explanation are gone. A subclass can override authorized? like any other channel. Adds a test that key creates no row. No change to what is accepted or rejected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
The README says a subclass of Y::DocumentChannel can override authorized? directly, with the located record available as `record`, and that the tag's channel: option points the element at that subclass. Neither was covered. The channel test subscribes through such a subclass and checks that a denial there still creates no document row; the helper test checks the attribute lands on the element. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
A grant lived as long as GlobalID's signed-id default, one month under
Rails, and collaborative_sgid gave no way to shorten it. Shortening it
alone would not have helped: the grant is baked into the page, and
Action Cable resubscribes with it after every network drop, so a grant
shorter than an editing session would block the editor at the first
reconnect after it expired.
collaborative_sgid and collaborative_document_tag take expires_in:. The
key is only passed through when given, since an explicit nil means
"never expire" to GlobalID. The tag also takes refresh:, a URL rendered
on the element.
The client renews on rejection. When a subscription is refused and the
element has a refresh URL, the session fetches it with the session
cookie, expects { "grant": ... }, and resubscribes under the new grant
on the same provider, keeping the document, the pending queue,
awareness, and the ack route. The application's action re-runs its own
authorization before minting, so every reconnect after expiry is a
fresh permission check. One attempt per rejection: a failed fetch or a
second rejection blocks as before. Nothing renews on a timer, so a
healthy subscription is never interrupted.
Tests: expiry through both helpers; the element passes the attribute;
five session tests for renewal, failure, the one-attempt rule, and
retry with the current grant; and a real-Chrome scenario with a
two-second grant, a forced socket drop, and an edit that persists
through the renewed session.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
The refresh endpoint mints write access. The README example showed the authorization check but not what omitting it means: grants for anyone who can reach the URL, and nothing gained from a short expiry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
…ome last The tag helper passed expires_in: through even when nil and relied on the model to drop it. It now calls collaborative_sgid without the key unless one was given. The order of the two keyword splats is deliberate and now says so: the helper's grant and name come last so a template cannot override them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
The comment claimed a token could not locate a record for another channel. The purpose names only the attribute; any channel calling locate with that attribute resolves the record, by design. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TeaovH2jyHARHJSyQ8Afo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #82.
The server side of a collaborative document is now the gem's job. An app renders one tag and writes no channel.
The tag and the channel
collaborative_document_tag(record, name)renders a mount element carrying a signed GlobalID scoped to that record and attribute, the wayturbo_stream_fromsigns its stream names.Y::DocumentChannelships in the gem, verifies the grant on subscribe, derives the document from it, and records every change before acknowledging it. A missing, tampered, wrong-attribute, or destroyed-record grant is rejected. Render the tag only where the request is already authorized to edit the record; possession of the grant is what the channel checks.Y::Collaborativeis the handshake underneath:sgid_purpose,locate, andcollaborative_sgid, included into Active Record by the engine. A token minted for:bodyverifies only under:body's purpose.authorized? is fail-closed (breaking)
sync_subscribednow callsauthorized?(key)before it opens a stream or serves state, and the concern's default returns false. A channel that never defines the method rejects every subscriber, and the rejection log names the method to define. Existing channels that relied on the previous open-by-default behavior must add the method. This ships as yrby-rails 0.7.0.Default storage
A channel that declares no
on_load/on_changegetsY::Documentstorage automatically, the way Action Text owns its tables. Declaring either hook replaces the default. Outside a yrby-rails app the concern still fails closed until both are declared. The generator is now migration-only; the channel template is gone.Encrypted attributes
has_collaborative_document :body, encrypted: truedeclares encryption on the model.Y::DocumentChannelreads the declaration and routes loads and appends for that attribute throughY::EncryptedDocument, which stores state and update payloads through Active Record encryption on the same tables. Encryption is a property of the attribute's storage, never something a page or client selects.The browser half
yrby-client/elementregisters<yrby-document>, which connects itself with the tag's grant, keeps itsY.Docacross DOM moves and Turbo restores, exposesdoc/provider/whenSynced, and dispatches a bubblingyrby:synced. All elements on a page share one consumer, created from@rails/actioncableby default (a new optional peer, dynamically imported) or assigned once for AnyCable.Docs
The README leads with the tag, the element, and the read-back, and its prose is rewritten throughout. Code blocks, headings, links, and the editors table are unchanged; the readme test executes every Ruby block.
Gem suite green.