diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml
index 9e4814b..fc15db3 100644
--- a/.github/workflows/specs.yml
+++ b/.github/workflows/specs.yml
@@ -19,6 +19,15 @@ jobs:
# pinned in Gemfile.lock (BUNDLED WITH).
bundler-cache: true
+ - name: Set up Node
+ uses: actions/setup-node@v5
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install the Castle browser SDK
+ run: npm ci
+
- name: Set up the test database
run: |
cp config/database.yml.example config/database.yml
diff --git a/Dockerfile b/Dockerfile
index ece6878..7b73e9b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,11 @@
# syntax=docker/dockerfile:1
+# Fetch the Castle browser SDK from npm (served at runtime from node_modules).
+FROM node:20-slim AS frontend
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
# ---------- Build stage ----------
FROM ruby:3.4.9-slim AS build
@@ -21,6 +27,7 @@ RUN gem install bundler -v "${BUNDLER_VERSION}" && \
rm -rf "${BUNDLE_PATH}"/ruby/*/cache
COPY . .
+COPY --from=frontend /app/node_modules/@castleio/castle-js/dist ./node_modules/@castleio/castle-js/dist
# The real database.yml is environment-specific and git-ignored; derive it from
# the committed example so the build is reproducible from a clean checkout.
diff --git a/README.md b/README.md
index 068ce87..598823f 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,13 @@ cd castle-ruby-example
bundle install
```
+The Castle browser SDK is served at runtime straight from `node_modules`, so
+install it too:
+
+```bash
+npm install
+```
+
Configure your environment and database:
```bash
diff --git a/app/controllers/vendor/castle_js_controller.rb b/app/controllers/vendor/castle_js_controller.rb
new file mode 100644
index 0000000..03384dd
--- /dev/null
+++ b/app/controllers/vendor/castle_js_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Vendor
+ # Serves the Castle browser SDK from the npm install (node_modules).
+ class CastleJsController < ActionController::Base
+ DIST = Rails.root.join('node_modules/@castleio/castle-js/dist')
+
+ skip_forgery_protection
+
+ def show
+ path = resolved_file
+ return head :not_found unless path
+
+ send_file path, type: 'application/javascript', disposition: 'inline'
+ end
+
+ private
+
+ def resolved_file
+ root = DIST.expand_path
+ candidate = root.join(params[:filename].to_s).expand_path
+ return unless candidate.to_s.start_with?("#{root}#{File::SEPARATOR}")
+ return unless candidate.file?
+
+ candidate
+ end
+ end
+end
diff --git a/app/views/layouts/_castle_js.html.erb b/app/views/layouts/_castle_js.html.erb
index 99bcc46..f937508 100644
--- a/app/views/layouts/_castle_js.html.erb
+++ b/app/views/layouts/_castle_js.html.erb
@@ -1,7 +1,7 @@
<% if ENV["CASTLE_PK"].present? %>
<%# Castle browser SDK. Mints the request token that ties the browser to the %>
<%# server-side risk/filter calls. See app/assets/javascripts/castle.js. %>
-
+