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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ CASTLE_API_SECRET=

# Publishable key, used by the browser SDK to mint request tokens.
CASTLE_PK=

# Optional: override the seeded demo user used by the login page quick-fill
# buttons (defaults shown). The password must be at least 6 characters.
# valid_username=clark.kent@dailyplanet.com
# valid_name=Clark Kent
# valid_password=castle1234
# invalid_password=qwerty
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ gem 'castle-rb', '~> 9.1'
gem 'devise', '~> 5.0'
gem 'dotenv-rails'
gem 'hamlit-rails'
gem 'puma', '~> 6.4'
gem 'puma', '~> 7.2'
gem 'rails', '~> 8.1.3'
gem 'responders'
gem 'simple_form'
Expand Down
41 changes: 20 additions & 21 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ GEM
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.1)
net-imap (0.6.4)
msgpack (1.8.3)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
Expand All @@ -176,7 +176,7 @@ GEM
psych (5.4.0)
date
stringio
puma (6.6.1)
puma (7.2.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
Expand Down Expand Up @@ -239,14 +239,14 @@ GEM
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-rails (8.0.4)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.7)
securerandom (0.4.1)
simple_form (5.4.1)
Expand All @@ -266,11 +266,11 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (2.9.4)
sqlite3 (2.9.5)
mini_portile2 (~> 2.8.0)
sqlite3 (2.9.4-aarch64-linux-gnu)
sqlite3 (2.9.4-arm64-darwin)
sqlite3 (2.9.4-x86_64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
stringio (3.2.0)
tailwindcss-rails (3.3.2)
railties (>= 7.0.0)
Expand All @@ -290,12 +290,11 @@ GEM
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
websocket-driver (0.8.0)
railties (>= 8.0.0)
websocket-driver (0.8.1)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
Expand All @@ -316,7 +315,7 @@ DEPENDENCIES
factory_bot_rails
faker
hamlit-rails
puma (~> 6.4)
puma (~> 7.2)
rails (~> 8.1.3)
rails-controller-testing
responders
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ SDK (9.x).
- **browser SDK** – the `@castleio/castle-js` SDK mints a request token in the
browser for every Castle-bound form (sign up, login, profile update, custom
event, logout) and forwards it to the backend.
- **Castle activity panel** – every flow renders the endpoint called, the
payload sent to Castle and the response (verdict, risk score and signals) so
you can see exactly what each call does.

## Screenshots

Expand Down
2 changes: 1 addition & 1 deletion app/assets/builds/tailwind.css

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions app/assets/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,72 @@ table.table td {
.field_with_errors {
display: contents;
}

/*
* Castle activity panel: the endpoint badge, verdict banner (action + risk
* score + signals) and the JSON payload/response blocks. Kept in sync with the
* Node, Python and PHP Castle example apps.
*/
.result-block {
@apply mt-4;
}

.result-block .label {
@apply mb-1.5 text-[0.78rem] font-bold uppercase tracking-wide text-muted;
}

pre.json {
@apply m-0 overflow-auto rounded-lg border border-border bg-bg-soft p-4 font-mono text-[0.85rem] leading-relaxed;
}

.badge {
@apply inline-block rounded-full border border-border px-2 py-0.5 text-xs font-semibold;
}

.badge.endpoint {
@apply border-accent/40 bg-accent/10 font-mono text-accent;
}

.verdict {
@apply flex items-center gap-3.5 rounded-lg border border-border bg-surface-2 px-4 py-2.5;
}

.verdict-action {
@apply rounded-full px-2.5 py-1 text-[0.85rem] font-bold uppercase tracking-wider;
}

.verdict-score {
@apply text-[0.9rem] text-muted;
}

.verdict-allow {
@apply border-success/40 bg-success/10;
}

.verdict-allow .verdict-action {
@apply bg-success text-white;
}

.verdict-challenge {
@apply border-challenge/40 bg-challenge/10;
}

.verdict-challenge .verdict-action {
@apply bg-challenge text-ink;
}

.verdict-deny {
@apply border-danger/40 bg-danger/10;
}

.verdict-deny .verdict-action {
@apply bg-danger text-white;
}

.signals {
@apply mt-2.5 flex flex-wrap gap-1.5;
}

.signals .chip {
@apply rounded-full border border-border bg-bg-soft px-2 py-0.5 font-mono text-xs text-muted;
}
2 changes: 2 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

# Main application controller
class ApplicationController < ActionController::Base
include CastleReporting

self.responder = ApplicationResponder
respond_to :html

Expand Down
106 changes: 106 additions & 0 deletions app/controllers/concerns/castle_reporting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# frozen_string_literal: true

# Captures the Castle API interactions made during a request (the endpoint
# called, the payload we sent and the response we got back) so the rendered page
# can show the verdict, risk score and signals. This mirrors the transparency of
# the Castle demo apps in the other languages (Node, Python, PHP).
#
# Results captured in the current request are exposed to the view through the
# `castle_results` helper. For flows that redirect (e.g. a successful login),
# call `persist_castle_results` right before redirecting so the next page can
# still render them via the flash.
module CastleReporting
extend ActiveSupport::Concern

# Hard cap on the whole flashed payload so a large `/risk` response can never
# overflow the (4 KB) cookie-backed session on a redirecting flow. When the
# compacted results still exceed this, the response bodies are dropped.
MAX_FLASHED_TOTAL_BYTES = 2_500

# Request tokens are long; truncate them when persisting to the flash.
MAX_FLASHED_TOKEN_CHARS = 24

included do
helper_method :castle_results
end

private

# Records a single Castle call for display. `response` is the Hash returned by
# the SDK (risk/filter/log), or nil when the call raised.
def record_castle_result(endpoint:, payload:, response: nil, error: nil)
recorded_castle_results << {
'endpoint' => endpoint.to_s,
'payload' => stringify_castle(payload),
'response' => stringify_castle(response),
'error' => error&.to_s
}
end

# The results to render: those captured in this request, otherwise any carried
# over a redirect via the flash.
def castle_results
if recorded_castle_results.present?
recorded_castle_results
else
flash[:castle_results] || []
end
end

# Persists the captured results across a redirect. The full response can be
# large, and the cookie-backed session is capped at ~4 KB, so the persisted
# copy keeps only the verdict, risk score and signal names. The flash is swept
# once the next request has rendered them.
def persist_castle_results
return if recorded_castle_results.blank?

compacted = recorded_castle_results.map { |entry| compact_for_flash(entry) }
compacted = compacted.map { |entry| entry.except('response') } if compacted.to_json.bytesize > MAX_FLASHED_TOTAL_BYTES

flash[:castle_results] = compacted
end

# Extracts the policy action ('allow', 'challenge' or 'deny') from a Castle
# response, tolerating both symbol-keyed (fresh) and string-keyed (flash)
# hashes.
def castle_action(response)
return unless response.is_a?(Hash)

response.dig(:policy, :action) || response.dig('policy', 'action')
end

def recorded_castle_results
@recorded_castle_results ||= []
end

def stringify_castle(value)
value.is_a?(Hash) ? value.deep_stringify_keys : value
end

# Shrinks an entry to the essentials that fit in the cookie-backed session:
# the verdict, the risk score and the signal names (not their bodies), plus a
# truncated request token in the echoed payload.
def compact_for_flash(entry)
entry.merge(
'payload' => compact_payload(entry['payload']),
'response' => compact_response(entry['response'])
)
end

def compact_payload(payload)
return payload unless payload.is_a?(Hash)
return payload unless payload['request_token'].is_a?(String)
return payload if payload['request_token'].length <= MAX_FLASHED_TOKEN_CHARS

payload.merge('request_token' => "#{payload['request_token'][0, MAX_FLASHED_TOKEN_CHARS]}…")
end

def compact_response(response)
return response unless response.is_a?(Hash)

compact = response.slice('policy', 'risk')
signals = response['signals']
compact['signals'] = signals.keys if signals.is_a?(Hash)
compact
end
end
13 changes: 8 additions & 5 deletions app/controllers/users/custom_events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ class CustomEventsController < ApplicationController

# Records a custom event with the non-blocking log endpoint.
def create
castle.log(
payload = {
type: '$custom',
name: 'Demo custom event',
status: '$succeeded',
request_token: castle_request_token,
user: { id: current_user.id, email: current_user.email }
)
rescue Castle::Error
nil
user: { id: current_user.id.to_s, email: current_user.email }
}
result = castle.log(**payload)
record_castle_result(endpoint: 'log', payload: payload, response: result)
rescue Castle::Error => e
record_castle_result(endpoint: 'log', payload: payload, error: e)
ensure
persist_castle_results
redirect_to edit_users_profile_path, notice: t('.sent')
end
end
Expand Down
14 changes: 9 additions & 5 deletions app/controllers/users/lists_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@ class ListsController < ApplicationController
# Renders the form (and any result from a previous POST).
def show; end

# Creates a list and then fetches every list, echoing the Castle responses.
# Creates a list and then fetches every list, recording the Castle responses.
def create
@payload = {
payload = {
name: params[:name].presence || 'demo-blocklist',
color: params[:color].presence || '$red',
primary_field: params[:primary_field].presence || 'user.email'
}

created = castle.create_list(@payload)
created = castle.create_list(payload)
all_lists = castle.get_all_lists
@result = { created: created, all_lists: all_lists }
record_castle_result(
endpoint: 'lists',
payload: payload,
response: { created: created, all_lists: all_lists }
)
rescue Castle::Error => e
@error = e.message
record_castle_result(endpoint: 'lists', payload: payload, error: e)
ensure
render :show
end
Expand Down
12 changes: 6 additions & 6 deletions app/controllers/users/password_resets_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ def show; end
# a successful one. Either way we only log the event to Castle.
def create
status = current_user.valid_password?(params[:password].to_s) ? '$failed' : '$succeeded'
@status = status

castle.log(
payload = {
type: '$password_reset',
status: status,
request_token: castle_request_token,
user: { id: current_user.id, email: current_user.email }
)
@logged = true
user: { id: current_user.id.to_s, email: current_user.email }
}
result = castle.log(**payload)
record_castle_result(endpoint: 'log', payload: payload, response: result)
rescue Castle::Error => e
@error = e.message
record_castle_result(endpoint: 'log', payload: payload, error: e)
ensure
render :show
end
Expand Down
Loading