Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 86 additions & 3 deletions v2/ruby/lib/terminalwire/v2/rails.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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`.
Expand Down
67 changes: 67 additions & 0 deletions v2/ruby/spec/server/rails_session_spec.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions v2/ruby/terminalwire.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading