From a33d27aea30894a5511a6848ed63de8e99a9b439 Mon Sep 17 00:00:00 2001 From: Brad Gessler Date: Mon, 22 Jun 2026 17:04:13 -0700 Subject: [PATCH] =?UTF-8?q?v2/rails:=20native=20Session=20(no=20v1=20gem)?= =?UTF-8?q?=20=E2=80=94=20graceful=20sign-out=20on=20bad/old=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Terminalwire::V2::Rails::Session: a JWT-backed, client-stored session (v1's Terminalwire::Rails::Session, ported onto the v2 context's identical file/directory/ storage_path API), and point the v2 shell's `session` shim at it. So a v2-only app needs no v1 gem for current_user/login/whoami. Resilient by design: a missing, empty, tampered, or wrong-secret session reads as EMPTY rather than raising — upgrading v1 -> v2 (or rotating the secret) just signs the user out ("log in again"), it never crashes a command. Adds the jwt dependency. Specs: 6 session cases; full suite 96/0. --- v2/ruby/lib/terminalwire/v2/rails.rb | 89 ++++++++++++++++++++++- v2/ruby/spec/server/rails_session_spec.rb | 67 +++++++++++++++++ v2/ruby/terminalwire.gemspec | 2 + 3 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 v2/ruby/spec/server/rails_session_spec.rb diff --git a/v2/ruby/lib/terminalwire/v2/rails.rb b/v2/ruby/lib/terminalwire/v2/rails.rb index 38bfc41..46c8cc8 100644 --- a/v2/ruby/lib/terminalwire/v2/rails.rb +++ b/v2/ruby/lib/terminalwire/v2/rails.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +require "forwardable" +require "pathname" +require "jwt" + require "terminalwire/v2" # full v2 server (runtime, handler, …) require "terminalwire/v2/server/rack" # the v2 Rack endpoint require "terminalwire/v2/server/dual_thor" # one Thor CLI, both protocols @@ -12,11 +16,10 @@ # which v2 implements identically). PUBLIC, not protected: Ruby 4's Forwardable — # used by the v1 `def_delegators :shell, :session` — refuses to forward to a # non-public method (the "forwarding to private method" warning is now a hard error). -# Terminalwire::Rails::Session is referenced lazily so this file needn't hard-depend -# on the v1 rails gem at load (it's present at call time in a dual-transition app). +# Backed by the v2-native Terminalwire::V2::Rails::Session (below) — no v1 gem needed. Terminalwire::V2::Server::Thor::Shell.class_eval do def session - @session ||= ::Terminalwire::Rails::Session.new(context: context) + @session ||= Terminalwire::V2::Rails::Session.new(context: context) end end @@ -41,6 +44,86 @@ module V2 module Rails SUBPROTOCOL = "terminalwire.v2" + # A JWT-backed session stored on the CLIENT — the v2-native version of v1's + # Terminalwire::Rails::Session, so a v2-only app needs no v1 gem. It reads/writes + # an encrypted blob via the context (file/directory/storage_path, which v2 + # implements identically to v1), signed with the app's secret_key_base. + # + # Resilient by design: a missing, empty, tampered, or wrong-key session reads as + # EMPTY, so the user simply logs in again — upgrading v1 -> v2 (or rotating the + # secret) never crashes a command, it just signs them out. + class Session + FILENAME = "session.jwt" + EMPTY_SESSION = {}.freeze + + extend Forwardable + def_delegators :read, :dig, :fetch, :[] + + def initialize(context:, path: nil, secret_key: self.class.secret_key) + @context = context + @path = Pathname.new(path || context.storage_path) + @config_file_path = @path.join(FILENAME) + @secret_key = secret_key + ensure_file + end + + # The session payload, or EMPTY_SESSION when there isn't a valid one (missing, + # empty, tampered, wrong key, unreadable). To the user these all mean the same + # thing — log in again — so none of them raise. + def read + token = @context.file.read(@config_file_path) + return EMPTY_SESSION if token.nil? || token.to_s.empty? + + JWT.decode(token, @secret_key, true, algorithm: "HS256").first || EMPTY_SESSION + rescue StandardError + EMPTY_SESSION + end + + def reset + @context.file.delete(@config_file_path) + rescue StandardError + nil + end + + def edit + config = read.dup + yield config + write(config) + end + + def []=(key, value) + edit { |config| config[key] = value } + end + + def write(config) + token = JWT.encode(config, @secret_key, "HS256") + @context.file.write(@config_file_path, token) + end + + def self.secret_key + ::Rails.application.secret_key_base + end + + private + + # Best-effort: seed an empty session file if absent. A failure here is not + # fatal — read/write degrade gracefully on their own. + def ensure_file + return true if file_exist? + + @context.directory.create(@path) + write(EMPTY_SESSION) + rescue StandardError + nil + end + + def file_exist? + @context.file.exist?(@config_file_path) + rescue StandardError + false + end + end + # Returns a Rack endpoint that serves `cli` over both protocols. Pass `v1:`/`v2:` # to override the handlers (tests, custom adapters); by default it builds the # stock v1 `Terminalwire::Rails::Thor` and v2 `Terminalwire::V2::Server::Rack`. diff --git a/v2/ruby/spec/server/rails_session_spec.rb b/v2/ruby/spec/server/rails_session_spec.rb new file mode 100644 index 0000000..b13d35c --- /dev/null +++ b/v2/ruby/spec/server/rails_session_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "spec_helper" +require "terminalwire/v2/rails" + +RSpec.describe Terminalwire::V2::Rails::Session do + # An in-memory stand-in for the client: file.read on a missing path raises, just + # like a real file.read request that comes back :not_found. + class FakeContext + def initialize = @files = {} + def storage_path = "/home/user/.terminalwire/storage/example" + def file = File.new(@files) + def directory = Directory.new + + class File + def initialize(store) = @store = store + def read(path) = @store.fetch(path.to_s) { raise "no such file: #{path}" } + def write(path, content) = @store[path.to_s] = content + def exist?(path) = @store.key?(path.to_s) + def delete(path) = @store.delete(path.to_s) + end + + class Directory + def create(_path) = true + end + end + + let(:secret) { "test-secret-key-base" } + let(:ctx) { FakeContext.new } + subject(:session) { described_class.new(context: ctx, secret_key: secret) } + + def path_for(context) = Pathname.new(context.storage_path).join(described_class::FILENAME) + + it "starts empty" do + expect(session.read).to eq({}) + expect(session["user_id"]).to be_nil + expect(session.dig("user_id")).to be_nil + end + + it "persists values across a fresh read (JWT round-trip, string keys)" do + session["user_id"] = 42 + fresh = described_class.new(context: ctx, secret_key: secret) + expect(fresh["user_id"]).to eq(42) + end + + it "reads as empty — not a crash — when the token is garbage/tampered" do + ctx.file.write(path_for(ctx), "not-a-jwt") + expect(session.read).to eq({}) + end + + it "reads as empty when signed with a different secret (i.e. log in again)" do + session["user_id"] = 42 + other = described_class.new(context: ctx, secret_key: "rotated-secret") + expect(other.read).to eq({}) + end + + it "reads as empty when the session file is missing entirely" do + ctx.file.delete(path_for(ctx)) + expect(session.read).to eq({}) + end + + it "reset clears the session" do + session["user_id"] = 42 + session.reset + expect(session.read).to eq({}) + end +end diff --git a/v2/ruby/terminalwire.gemspec b/v2/ruby/terminalwire.gemspec index 8a77755..bb9ef16 100644 --- a/v2/ruby/terminalwire.gemspec +++ b/v2/ruby/terminalwire.gemspec @@ -30,6 +30,8 @@ Gem::Specification.new do |spec| spec.add_dependency "msgpack", "~> 1.7" # base64 left the Ruby default gems in 3.4; the conformance loader needs it. spec.add_dependency "base64", "~> 0.2" + # JWT-backed client session (Terminalwire::V2::Rails::Session). + spec.add_dependency "jwt", "~> 2.7" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.13"