Skip to content

Ship the document channel, the signed grant, and a fail-closed authorized? - #83

Open
jpcamara wants to merge 30 commits into
mainfrom
feat/authorized-documents
Open

Ship the document channel, the signed grant, and a fail-closed authorized?#83
jpcamara wants to merge 30 commits into
mainfrom
feat/authorized-documents

Conversation

@jpcamara

@jpcamara jpcamara commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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 way turbo_stream_from signs its stream names. Y::DocumentChannel ships 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::Collaborative is the handshake underneath: sgid_purpose, locate, and collaborative_sgid, included into Active Record by the engine. A token minted for :body verifies only under :body's purpose.

authorized? is fail-closed (breaking)

sync_subscribed now calls authorized?(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_change gets Y::Document storage 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: true declares encryption on the model. Y::DocumentChannel reads the declaration and routes loads and appends for that attribute through Y::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/element registers <yrby-document>, which connects itself with the tag's grant, keeps its Y.Doc across DOM moves and Turbo restores, exposes doc/provider/whenSynced, and dispatches a bubbling yrby:synced. All elements on a page share one consumer, created from @rails/actioncable by 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.

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
jpcamara changed the base branch from feat/read-array to main September 5, 2026 03:07
@jpcamara
jpcamara force-pushed the feat/authorized-documents branch from dabdba1 to b46dde3 Compare September 5, 2026 03:07
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
jpcamara force-pushed the feat/authorized-documents branch from a54fdc5 to c851138 Compare September 8, 2026 01:32
jpcamara and others added 6 commits September 7, 2026 22:10
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
jpcamara and others added 5 commits September 10, 2026 16:14
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant