From 07362a95f1fdee9e2c15d756078d8a56f50c2055 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Thu, 11 Jun 2026 14:22:50 +0200 Subject: [PATCH 1/4] Show the Castle payload and verdict in the UI across all flows Add a CastleReporting controller concern that records each Castle call (endpoint, request payload and response) during a request and renders it in a shared "Castle activity" panel: an endpoint badge, a verdict banner with the policy action, risk score and signal names, and the payload and response JSON. Results from flows that redirect (login, sign up, profile update, logout, custom event) are carried over the redirect through the flash, compacted to the verdict summary so they always fit the cookie session; the login surfaces both the filter and risk steps. Replace the per-page API result partial with the shared panel on the lists, privacy and password-reset pages, and add the result-panel component styles. Send user.id to Castle as a string on the risk, log and matching_user_id calls. Add specs covering the reporting concern (compaction and cookie budget) and the rendered panel. --- README.md | 3 + app/assets/builds/tailwind.css | 2 +- .../stylesheets/application.tailwind.css | 69 ++++++++++++ app/controllers/application_controller.rb | 2 + app/controllers/concerns/castle_reporting.rb | 106 ++++++++++++++++++ .../users/custom_events_controller.rb | 13 ++- app/controllers/users/lists_controller.rb | 14 ++- .../users/password_resets_controller.rb | 12 +- app/controllers/users/privacy_controller.rb | 22 ++-- app/controllers/users/profiles_controller.rb | 17 ++- .../users/registrations_controller.rb | 32 ++++-- app/controllers/users/sessions_controller.rb | 87 +++++++++----- app/views/layouts/application.html.haml | 2 + app/views/layouts/devise.html.haml | 2 + app/views/users/lists/show.html.haml | 2 - .../users/password_resets/show.html.haml | 10 -- app/views/users/privacy/show.html.haml | 2 - app/views/users/shared/_api_result.html.haml | 4 - .../users/shared/_castle_results.html.haml | 31 +++++ .../concerns/castle_reporting_spec.rb | 87 ++++++++++++++ .../users/custom_events_controller_spec.rb | 2 +- .../users/lists_controller_spec.rb | 6 + .../users/password_resets_controller_spec.rb | 2 +- .../users/profiles_controller_spec.rb | 8 +- .../users/sessions_controller_spec.rb | 25 ++++- 25 files changed, 462 insertions(+), 100 deletions(-) create mode 100644 app/controllers/concerns/castle_reporting.rb delete mode 100644 app/views/users/shared/_api_result.html.haml create mode 100644 app/views/users/shared/_castle_results.html.haml create mode 100644 spec/controllers/concerns/castle_reporting_spec.rb diff --git a/README.md b/README.md index db1db09..068ce87 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 724b2b4..294f3a1 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}body{min-height:100vh;--tw-bg-opacity:1;background-color:rgb(246 248 252/var(--tw-bg-opacity,1));color:rgb(15 23 41/var(--tw-text-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:15px;line-height:1.625;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-image:radial-gradient(1200px 600px at 80% -10%,rgba(54,94,237,.12),transparent 60%)}a,body{--tw-text-opacity:1}a{color:rgb(54 94 237/var(--tw-text-opacity,1));text-decoration-line:none}a:hover{text-decoration-line:underline}h1,h2,h3,h4{font-weight:600;line-height:1.25}p{margin-bottom:.75rem}code{border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em;padding:.125rem .375rem}.relative{position:relative}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.block{display:block}.inline{display:inline}.table{display:table}.hidden{display:none}.w-full{width:100%}.max-w-\[640px\]{max-width:640px}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-3{gap:.75rem}.overflow-auto{overflow:auto}.whitespace-pre-wrap{white-space:pre-wrap}.border{border-width:1px}.pl-5{padding-left:1.25rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.text-\[0\.82rem\]{font-size:.82rem}.text-\[0\.9rem\]{font-size:.9rem}.text-\[1\.2rem\]{font-size:1.2rem}.text-\[1\.3rem\]{font-size:1.3rem}.text-\[1\.4rem\]{font-size:1.4rem}.text-\[2rem\]{font-size:2rem}.text-muted{--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.navbar{border-bottom-width:1px;flex-wrap:wrap;gap:1.5rem;position:sticky;top:0;z-index:50;--tw-border-opacity:1;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:hsla(0,0%,100%,.8);border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding:.875rem 1.5rem}.brand,.navbar{align-items:center;display:flex}.brand{font-size:1.05rem;font-weight:700;gap:.5rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.brand:hover{text-decoration-line:none}.brand-logo{flex-shrink:0;height:1.4rem;width:1.4rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1));filter:drop-shadow(0 0 8px rgba(54,94,237,.35))}.brand-logo-lg{height:3rem;width:3rem}.nav-links{align-items:center;display:flex;flex-wrap:wrap;gap:1.25rem;margin-left:auto}.nav-links a{font-size:.92rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links a:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none}.nav-links form{display:inline;margin:0}.nav-links form button{background-color:transparent;border-width:0;cursor:pointer;font-size:.92rem;padding:0;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links form button:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.tag{background-color:rgba(54,94,237,.1);border-color:rgba(54,94,237,.4);border-radius:9999px;border-width:1px;font-size:.75rem;font-weight:600;line-height:1rem;padding:.125rem .5rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1))}.container-page{margin-left:auto;margin-right:auto;max-width:1120px;padding:2rem 1.5rem 4rem}.container-narrow{margin-left:auto;margin-right:auto;max-width:420px;padding:4rem 1.5rem;width:100%}.card{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1.5rem;--tw-shadow:0 1px 3px rgba(16,24,40,.06),0 8px 24px rgba(16,24,40,.06);--tw-shadow-colored:0 1px 3px var(--tw-shadow-color),0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.eyebrow{font-size:.75rem;font-weight:700;letter-spacing:.05em;line-height:1rem;margin-bottom:.375rem;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.hero{margin-bottom:2rem;padding:2rem}.feature,.hero{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.feature{display:block;padding:1.25rem;text-align:left;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.feature:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.section-head{border-bottom-width:1px;margin-bottom:.75rem;margin-top:2rem;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding-bottom:.5rem}.prose-list>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.field{margin-bottom:.875rem}.field label{display:block;font-size:.82rem;font-weight:600;margin-bottom:.375rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.input,input[type=email],input[type=password],input[type=text]{border-radius:9px;border-width:1px;width:100%;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 249/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.95rem;padding:.625rem .75rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.input:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));box-shadow:0 0 0 3px rgba(54,94,237,.14);outline:2px solid transparent;outline-offset:2px}.btn{border-radius:9px;border-width:1px;cursor:pointer;display:inline-block;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.92rem;font-weight:600;padding:.625rem 1rem;text-align:center;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn:hover{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.btn:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-primary{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(54 94 237/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(42 78 209/var(--tw-bg-opacity,1))}.btn-alt,.btn-ghost{background-color:transparent}.btn-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.5);--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.btn-row{display:flex;flex-wrap:wrap;gap:.625rem;margin-top:1rem}.alert{align-items:center;border-radius:9px;border-width:1px;display:flex;font-size:.92rem;gap:1rem;justify-content:space-between;margin-bottom:1rem;padding:.75rem 1rem}.alert-success{background-color:rgba(22,163,74,.1);border-color:rgba(22,163,74,.4)}.alert-danger,.alert-success{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.alert-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.4)}.alert .btn-close{background-color:transparent;border-width:0;cursor:pointer;font-size:1.125rem;line-height:1.75rem;line-height:1;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.alert .btn-close:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}table.table{border-collapse:collapse;border-radius:9px;font-size:.9rem;overflow:hidden;width:100%}table.table th{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-size:.78rem;font-weight:700;letter-spacing:.025em;padding:.5rem .75rem;text-align:left;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}table.table td{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(233 237 245/var(--tw-border-opacity,1));padding:.5rem .75rem;vertical-align:top}.lead{font-size:1.1rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.form-actions{margin-top:1.25rem}.error,.invalid-feedback{color:rgb(220 38 38/var(--tw-text-opacity,1))}.error,.hint,.invalid-feedback{display:block;font-size:.8rem;margin-top:.25rem;--tw-text-opacity:1}.hint{color:rgb(91 102 120/var(--tw-text-opacity,1))}.field_with_errors{display:contents} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}body{min-height:100vh;--tw-bg-opacity:1;background-color:rgb(246 248 252/var(--tw-bg-opacity,1));color:rgb(15 23 41/var(--tw-text-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:15px;line-height:1.625;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-image:radial-gradient(1200px 600px at 80% -10%,rgba(54,94,237,.12),transparent 60%)}a,body{--tw-text-opacity:1}a{color:rgb(54 94 237/var(--tw-text-opacity,1));text-decoration-line:none}a:hover{text-decoration-line:underline}h1,h2,h3,h4{font-weight:600;line-height:1.25}p{margin-bottom:.75rem}code{border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em;padding:.125rem .375rem}.relative{position:relative}.mx-auto{margin-left:auto;margin-right:auto}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline{display:inline}.table{display:table}.hidden{display:none}.w-full{width:100%}.max-w-\[640px\]{max-width:640px}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-3{gap:.75rem}.border{border-width:1px}.pl-5{padding-left:1.25rem}.text-center{text-align:center}.text-\[0\.9rem\]{font-size:.9rem}.text-\[1\.2rem\]{font-size:1.2rem}.text-\[1\.3rem\]{font-size:1.3rem}.text-\[1\.4rem\]{font-size:1.4rem}.text-\[2rem\]{font-size:2rem}.text-muted{--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.navbar{border-bottom-width:1px;flex-wrap:wrap;gap:1.5rem;position:sticky;top:0;z-index:50;--tw-border-opacity:1;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:hsla(0,0%,100%,.8);border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding:.875rem 1.5rem}.brand,.navbar{align-items:center;display:flex}.brand{font-size:1.05rem;font-weight:700;gap:.5rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.brand:hover{text-decoration-line:none}.brand-logo{flex-shrink:0;height:1.4rem;width:1.4rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1));filter:drop-shadow(0 0 8px rgba(54,94,237,.35))}.brand-logo-lg{height:3rem;width:3rem}.nav-links{align-items:center;display:flex;flex-wrap:wrap;gap:1.25rem;margin-left:auto}.nav-links a{font-size:.92rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links a:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none}.nav-links form{display:inline;margin:0}.nav-links form button{background-color:transparent;border-width:0;cursor:pointer;font-size:.92rem;padding:0;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.nav-links form button:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.tag{background-color:rgba(54,94,237,.1);border-color:rgba(54,94,237,.4);border-radius:9999px;border-width:1px;font-size:.75rem;font-weight:600;line-height:1rem;padding:.125rem .5rem;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1))}.container-page{margin-left:auto;margin-right:auto;max-width:1120px;padding:2rem 1.5rem 4rem}.container-narrow{margin-left:auto;margin-right:auto;max-width:420px;padding:4rem 1.5rem;width:100%}.card{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1));padding:1.5rem;--tw-shadow:0 1px 3px rgba(16,24,40,.06),0 8px 24px rgba(16,24,40,.06);--tw-shadow-colored:0 1px 3px var(--tw-shadow-color),0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.eyebrow{font-size:.75rem;font-weight:700;letter-spacing:.05em;line-height:1rem;margin-bottom:.375rem;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.hero{margin-bottom:2rem;padding:2rem}.feature,.hero{border-radius:14px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.feature{display:block;padding:1.25rem;text-align:left;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.feature:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.section-head{border-bottom-width:1px;margin-bottom:.75rem;margin-top:2rem;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding-bottom:.5rem}.prose-list>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.field{margin-bottom:.875rem}.field label{display:block;font-size:.82rem;font-weight:600;margin-bottom:.375rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.input,input[type=email],input[type=password],input[type=text]{border-radius:9px;border-width:1px;width:100%;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 249/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.95rem;padding:.625rem .75rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.input:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));box-shadow:0 0 0 3px rgba(54,94,237,.14);outline:2px solid transparent;outline-offset:2px}.btn{border-radius:9px;border-width:1px;cursor:pointer;display:inline-block;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.92rem;font-weight:600;padding:.625rem 1rem;text-align:center;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));text-decoration-line:none;transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.btn:hover{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));text-decoration-line:none}.btn:active{--tw-translate-y:1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-primary{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(54 94 237/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(42 78 209/var(--tw-bg-opacity,1))}.btn-alt,.btn-ghost{background-color:transparent}.btn-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.5);--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.btn-row{display:flex;flex-wrap:wrap;gap:.625rem;margin-top:1rem}.alert{align-items:center;border-radius:9px;border-width:1px;display:flex;font-size:.92rem;gap:1rem;justify-content:space-between;margin-bottom:1rem;padding:.75rem 1rem}.alert-success{background-color:rgba(22,163,74,.1);border-color:rgba(22,163,74,.4)}.alert-danger,.alert-success{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.alert-danger{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.4)}.alert .btn-close{background-color:transparent;border-width:0;cursor:pointer;font-size:1.125rem;line-height:1.75rem;line-height:1;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.alert .btn-close:hover{--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}table.table{border-collapse:collapse;border-radius:9px;font-size:.9rem;overflow:hidden;width:100%}table.table th{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));font-size:.78rem;font-weight:700;letter-spacing:.025em;padding:.5rem .75rem;text-align:left;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}table.table td{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(233 237 245/var(--tw-border-opacity,1));padding:.5rem .75rem;vertical-align:top}.lead{font-size:1.1rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.form-actions{margin-top:1.25rem}.error,.invalid-feedback{color:rgb(220 38 38/var(--tw-text-opacity,1))}.error,.hint,.invalid-feedback{display:block;font-size:.8rem;margin-top:.25rem;--tw-text-opacity:1}.hint{color:rgb(91 102 120/var(--tw-text-opacity,1))}.field_with_errors{display:contents}.result-block{margin-top:1rem}.result-block .label{font-size:.78rem;font-weight:700;letter-spacing:.025em;margin-bottom:.375rem;text-transform:uppercase;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}pre.json{border-radius:9px;border-width:1px;margin:0;overflow:auto;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 249/var(--tw-bg-opacity,1));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.85rem;line-height:1.625;padding:1rem}.badge{border-radius:9999px;border-width:1px;display:inline-block;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));font-size:.75rem;font-weight:600;line-height:1rem;padding:.125rem .5rem}.badge.endpoint{background-color:rgba(54,94,237,.1);border-color:rgba(54,94,237,.4);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1))}.verdict{align-items:center;border-radius:9px;border-width:1px;display:flex;gap:.875rem;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 251/var(--tw-bg-opacity,1));padding:.625rem 1rem}.verdict-action{border-radius:9999px;font-size:.85rem;font-weight:700;letter-spacing:.05em;padding:.25rem .625rem;text-transform:uppercase}.verdict-score{font-size:.9rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.verdict-allow{background-color:rgba(22,163,74,.1);border-color:rgba(22,163,74,.4)}.verdict-allow .verdict-action{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.verdict-challenge{background-color:rgba(245,158,11,.1);border-color:rgba(245,158,11,.4)}.verdict-challenge .verdict-action{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.verdict-deny{background-color:rgba(220,38,38,.1);border-color:rgba(220,38,38,.4)}.verdict-deny .verdict-action{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.signals{display:flex;flex-wrap:wrap;gap:.375rem;margin-top:.625rem}.signals .chip{border-radius:9999px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(238 242 249/var(--tw-bg-opacity,1));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.75rem;line-height:1rem;padding:.125rem .5rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))} \ No newline at end of file diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index f5c4247..2f42dcf 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -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; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index efc6523..6759f25 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,6 +5,8 @@ # Main application controller class ApplicationController < ActionController::Base + include CastleReporting + self.responder = ApplicationResponder respond_to :html diff --git a/app/controllers/concerns/castle_reporting.rb b/app/controllers/concerns/castle_reporting.rb new file mode 100644 index 0000000..1b6e786 --- /dev/null +++ b/app/controllers/concerns/castle_reporting.rb @@ -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 diff --git a/app/controllers/users/custom_events_controller.rb b/app/controllers/users/custom_events_controller.rb index f3c15f1..b2ad3a7 100644 --- a/app/controllers/users/custom_events_controller.rb +++ b/app/controllers/users/custom_events_controller.rb @@ -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 diff --git a/app/controllers/users/lists_controller.rb b/app/controllers/users/lists_controller.rb index d88fa42..782cf91 100644 --- a/app/controllers/users/lists_controller.rb +++ b/app/controllers/users/lists_controller.rb @@ -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 diff --git a/app/controllers/users/password_resets_controller.rb b/app/controllers/users/password_resets_controller.rb index cf9885a..abf8613 100644 --- a/app/controllers/users/password_resets_controller.rb +++ b/app/controllers/users/password_resets_controller.rb @@ -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 diff --git a/app/controllers/users/privacy_controller.rb b/app/controllers/users/privacy_controller.rb index 38a3f41..c8d093f 100644 --- a/app/controllers/users/privacy_controller.rb +++ b/app/controllers/users/privacy_controller.rb @@ -8,22 +8,24 @@ class PrivacyController < ApplicationController def show; end # Calls the request- or delete-user-data endpoint depending on which button - # was used, echoing the Castle response. + # was used, recording the Castle response. def create - @payload = { + payload = { identifier: params[:identifier].presence || current_user.email, identifier_type: params[:identifier_type].presence || '$email' } - @action = params[:commit_action] == 'delete' ? 'delete' : 'request' - @result = - if @action == 'delete' - castle.delete_user_data(@payload) - else - castle.request_user_data(@payload) - end + if params[:commit_action] == 'delete' + result = castle.delete_user_data(payload) + endpoint = 'privacy (delete)' + else + result = castle.request_user_data(payload) + endpoint = 'privacy (request)' + end + + record_castle_result(endpoint: endpoint, payload: payload, response: result) rescue Castle::Error => e - @error = e.message + record_castle_result(endpoint: 'privacy', payload: payload, error: e) ensure render :show end diff --git a/app/controllers/users/profiles_controller.rb b/app/controllers/users/profiles_controller.rb index 50f3c8d..102c042 100644 --- a/app/controllers/users/profiles_controller.rb +++ b/app/controllers/users/profiles_controller.rb @@ -21,18 +21,23 @@ def user_params end # After action that logs the profile update to Castle with the non-blocking - # log endpoint, noting whether the change was valid. + # log endpoint, noting whether the change was valid. On the redirecting + # (successful) path the result is persisted so the next page can show it. def track_profile_update status = current_user.valid? ? '$succeeded' : '$failed' - castle.log( + payload = { type: '$profile_update', status: status, 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 if response.redirect? end end end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index e156406..3417efe 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -6,7 +6,8 @@ class RegistrationsController < Devise::RegistrationsController layout 'devise' # Sign up with Castle filtering. A registration is anonymous activity, so the - # attempt is filtered before the account is created. + # attempt is filtered before the account is created. The call is recorded so + # the next page can show the payload sent to Castle and the verdict. # @note A 'challenge' verdict is treated as 'allow' here; a real app would # step up to MFA. 'deny' blocks the sign-up before the account is created. def create @@ -20,8 +21,9 @@ def create return end - if evaluate_registration_attempt == 'deny' + if castle_action(evaluate_registration_attempt) == 'deny' flash[:error] = t('.access_denied') + persist_castle_results redirect_to new_user_registration_url return end @@ -29,6 +31,7 @@ def create resource.save sign_up(resource_name, resource) set_flash_message! :notice, :signed_up + persist_castle_results respond_with resource, location: after_sign_up_path_for(resource) end @@ -36,17 +39,21 @@ def create # Filters the registration attempt while the visitor is still anonymous, # before the account is created (so the email goes in params). - # @return [String] the Castle policy action: 'allow', 'challenge' or 'deny' + # @return [Hash, nil] the Castle response, or nil when the call raised def evaluate_registration_attempt - castle.filter( + payload = { type: '$registration', status: '$attempted', request_token: castle_request_token, params: { email: resource.email } - ).dig(:policy, :action) - rescue Castle::Error + } + result = castle.filter(**payload) + record_castle_result(endpoint: 'filter', payload: payload, response: result) + result + rescue Castle::Error => e # Never block a sign-up because Castle is unhappy with the request. - 'allow' + record_castle_result(endpoint: 'filter', payload: payload, error: e) + nil end # Reports an invalid registration attempt (e.g. an email already taken) to @@ -55,17 +62,18 @@ def track_failed_registration email = sign_up_params[:email] matching_user = User.find_by(email: email) - options = { + payload = { type: '$registration', status: '$failed', request_token: castle_request_token, params: { email: email } } - options[:matching_user_id] = matching_user.id if matching_user + payload[:matching_user_id] = matching_user.id.to_s if matching_user - castle.filter(**options) - rescue Castle::Error - nil + result = castle.filter(**payload) + record_castle_result(endpoint: 'filter', payload: payload, response: result) + rescue Castle::Error => e + record_castle_result(endpoint: 'filter', payload: payload, error: e) end end end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 6247111..adb7678 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -10,26 +10,24 @@ class SessionsController < Devise::SessionsController # Sign in with Castle. The attempt is filtered first while the visitor is # still anonymous; a successful login is then risk-assessed, reusing the - # same request token. + # same request token. Each call is recorded so the next page can show the + # payload sent to Castle and the verdict that came back. # @note A 'challenge' verdict is treated as 'allow' here; a real app would # step up to MFA. 'deny' blocks the login. def create - if filter_login_attempt == 'deny' - flash[:error] = t('.access_denied') - redirect_to new_user_session_url - return - end + return deny_login if castle_action(filter_login_attempt) == 'deny' if warden.authenticate(auth_options) - if evaluate_login(current_user) == 'deny' + if castle_action(evaluate_login(current_user)) == 'deny' warden.logout - flash[:error] = t('.access_denied') - redirect_to new_user_session_url + deny_login else + persist_castle_results super end else track_failed_login + persist_castle_results throw(:warden) end end @@ -41,12 +39,8 @@ def destroy user_id = current_user&.id token = castle_request_token super - castle.log( - type: '$logout', - status: '$succeeded', - request_token: token, - user: { id: user_id } - ) + log_logout(user_id, token) + persist_castle_results end private @@ -56,18 +50,30 @@ def login_email params.dig(:user, AUTHENTICATION_KEY) end + # Denies the login: surface the reason, keep the recorded Castle calls and + # bounce back to the sign-in form. + def deny_login + flash[:error] = t('.access_denied') + persist_castle_results + redirect_to new_user_session_url + end + # Filters the login attempt while the visitor is still anonymous, before the # credentials are checked (so the email goes in params). - # @return [String] the Castle policy action: 'allow', 'challenge' or 'deny' + # @return [Hash, nil] the Castle response, or nil when the call raised def filter_login_attempt - castle.filter( + payload = { type: '$login', status: '$attempted', request_token: castle_request_token, params: { email: login_email } - ).dig(:policy, :action) - rescue Castle::Error - 'allow' + } + result = castle.filter(**payload) + record_castle_result(endpoint: 'filter', payload: payload, response: result) + result + rescue Castle::Error => e + record_castle_result(endpoint: 'filter', payload: payload, error: e) + nil end # Reports a failed login to the filter endpoint, resolving any existing user @@ -76,32 +82,51 @@ def track_failed_login email = login_email user = User.find_by(AUTHENTICATION_KEY => email) - options = { + payload = { type: '$login', status: '$failed', request_token: castle_request_token, params: { email: email } } - options[:matching_user_id] = user.id if user + payload[:matching_user_id] = user.id.to_s if user - castle.filter(**options) - rescue Castle::Error - nil + result = castle.filter(**payload) + record_castle_result(endpoint: 'filter', payload: payload, response: result) + rescue Castle::Error => e + record_castle_result(endpoint: 'filter', payload: payload, error: e) end # Sends a successful login to the risk endpoint and returns the verdict. # @param user [User] - # @return [String] the Castle policy action: 'allow', 'challenge' or 'deny' + # @return [Hash, nil] the Castle response, or nil when the call raised def evaluate_login(user) - castle.risk( + payload = { type: '$login', status: '$succeeded', request_token: castle_request_token, - user: { id: user.id, email: user.email } - ).dig(:policy, :action) - rescue Castle::Error + user: { id: user.id.to_s, email: user.email } + } + result = castle.risk(**payload) + record_castle_result(endpoint: 'risk', payload: payload, response: result) + result + rescue Castle::Error => e # Never lock a user out because Castle is unhappy with the request. - 'allow' + record_castle_result(endpoint: 'risk', payload: payload, error: e) + nil + end + + # Records the logout with the non-blocking log endpoint. + def log_logout(user_id, token) + payload = { + type: '$logout', + status: '$succeeded', + request_token: token, + user: { id: user_id&.to_s } + } + 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) end end end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 2e03c02..112da2f 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -28,4 +28,6 @@ = yield + = render 'users/shared/castle_results' + = render 'layouts/castle_js' diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 547fef9..b01c7ae 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -17,4 +17,6 @@ .card = yield + = render 'users/shared/castle_results' + = render 'layouts/castle_js' diff --git a/app/views/users/lists/show.html.haml b/app/views/users/lists/show.html.haml index 4834a84..ec8c266 100644 --- a/app/views/users/lists/show.html.haml +++ b/app/views/users/lists/show.html.haml @@ -21,5 +21,3 @@ = text_field_tag :primary_field, params[:primary_field].presence || 'user.email', class: 'input' .btn-row = submit_tag 'Create list', class: 'btn btn-primary' - -= render 'users/shared/api_result', error: @error, result: @result diff --git a/app/views/users/password_resets/show.html.haml b/app/views/users/password_resets/show.html.haml index f8afa49..895da56 100644 --- a/app/views/users/password_resets/show.html.haml +++ b/app/views/users/password_resets/show.html.haml @@ -24,13 +24,3 @@ = password_field_tag :password, nil, class: 'input' .btn-row = submit_tag 'Submit', class: 'btn btn-primary' - -- if @error - .alert.alert-danger.mt-4= @error -- elsif @logged - .alert.alert-success.mt-4 - Logged - %code= "$password_reset / #{@status}" - via the - %code log - endpoint. diff --git a/app/views/users/privacy/show.html.haml b/app/views/users/privacy/show.html.haml index fafb4a4..467e0cb 100644 --- a/app/views/users/privacy/show.html.haml +++ b/app/views/users/privacy/show.html.haml @@ -19,5 +19,3 @@ .btn-row = button_tag 'Request user data', name: 'commit_action', value: 'request', class: 'btn btn-primary' = button_tag 'Delete user data', name: 'commit_action', value: 'delete', class: 'btn' - -= render 'users/shared/api_result', error: @error, result: @result diff --git a/app/views/users/shared/_api_result.html.haml b/app/views/users/shared/_api_result.html.haml deleted file mode 100644 index 9d50eae..0000000 --- a/app/views/users/shared/_api_result.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- if error - .alert.alert-danger.mt-4= error -- elsif result - %pre.card.mt-4{ class: 'overflow-auto whitespace-pre-wrap font-mono text-[0.82rem]' }= JSON.pretty_generate(result) diff --git a/app/views/users/shared/_castle_results.html.haml b/app/views/users/shared/_castle_results.html.haml new file mode 100644 index 0000000..d2a1877 --- /dev/null +++ b/app/views/users/shared/_castle_results.html.haml @@ -0,0 +1,31 @@ +- entries = castle_results +- if entries.present? + .card.mt-6{ class: 'mx-auto max-w-[640px]' } + .eyebrow Castle activity + - entries.each do |entry| + - response = entry['response'] + - action = response.is_a?(Hash) ? response.dig('policy', 'action') : nil + - risk = response.is_a?(Hash) ? response['risk'] : nil + - signals = response.is_a?(Hash) ? response['signals'] : nil + - signal_names = signals.is_a?(Hash) ? signals.keys : Array(signals) + .result-block + %span.badge.endpoint= "/#{entry['endpoint']}" + - if action || risk + %div{ class: "verdict verdict-#{action || 'unknown'} mt-2" } + - if action + %span.verdict-action= action + - if risk + %span.verdict-score + risk + %strong= format('%.2f', risk) + - if signal_names.present? + .signals + - signal_names.each do |name| + %span.chip= name + - if entry['error'].present? + .alert.alert-danger.mt-2= entry['error'] + .label.mt-3 Payload sent to Castle + %pre.json= JSON.pretty_generate(entry['payload']) + - if response.present? + .label.mt-3 Response from Castle + %pre.json= JSON.pretty_generate(response) diff --git a/spec/controllers/concerns/castle_reporting_spec.rb b/spec/controllers/concerns/castle_reporting_spec.rb new file mode 100644 index 0000000..e046f37 --- /dev/null +++ b/spec/controllers/concerns/castle_reporting_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +RSpec.describe CastleReporting, type: :controller do + controller(ApplicationController) do + skip_before_action :authenticate_user! + + def index + record_castle_result( + endpoint: 'risk', + payload: { request_token: 'a' * 100, user: { id: '1' } }, + response: response_fixture + ) + persist_castle_results + redirect_to '/' + end + + private + + def response_fixture + { + policy: { action: 'allow' }, + risk: 0.4, + signals: { unreachable_email: {}, multiple_accounts_per_device: {} }, + # A large field that must not be persisted to the cookie session. + device: { fingerprint: 'z' * 6_000 } + } + end + end + + before { routes.draw { get 'index' => 'anonymous#index' } } + + describe 'persisting results across a redirect' do + before { get :index } + + it 'keeps the verdict and risk score' do + expect(flash[:castle_results].first['response']).to include( + 'policy' => { 'action' => 'allow' }, 'risk' => 0.4 + ) + end + + it 'reduces signals to their names' do + expect(flash[:castle_results].first['response']['signals']) + .to eq(%w[unreachable_email multiple_accounts_per_device]) + end + + it 'drops heavy fields such as device' do + expect(flash[:castle_results].first['response']).not_to have_key('device') + end + + it 'truncates the request token in the echoed payload' do + expect(flash[:castle_results].first['payload']['request_token']).to end_with('…') + end + + it 'stays within the cookie-session budget' do + expect(flash[:castle_results].to_json.bytesize).to be <= CastleReporting::MAX_FLASHED_TOTAL_BYTES + end + end + + context 'when even the compacted results exceed the budget' do + controller(ApplicationController) do + skip_before_action :authenticate_user! + + def index + record_castle_result( + endpoint: 'risk', + payload: {}, + response: { signals: (1..600).to_h { |i| ["signal_number_#{i}", {}] } } + ) + persist_castle_results + redirect_to '/' + end + end + + before do + routes.draw { get 'index' => 'anonymous#index' } + get :index + end + + it 'drops the response body entirely' do + expect(flash[:castle_results].first).not_to have_key('response') + end + + it 'still records the endpoint' do + expect(flash[:castle_results].first['endpoint']).to eq('risk') + end + end +end diff --git a/spec/controllers/users/custom_events_controller_spec.rb b/spec/controllers/users/custom_events_controller_spec.rb index 8d8975f..c86eb7e 100644 --- a/spec/controllers/users/custom_events_controller_spec.rb +++ b/spec/controllers/users/custom_events_controller_spec.rb @@ -24,7 +24,7 @@ name: 'Demo custom event', status: '$succeeded', request_token: nil, - user: { id: user.id, email: user.email } + user: { id: user.id.to_s, email: user.email } ) end end diff --git a/spec/controllers/users/lists_controller_spec.rb b/spec/controllers/users/lists_controller_spec.rb index 24b76e4..918382c 100644 --- a/spec/controllers/users/lists_controller_spec.rb +++ b/spec/controllers/users/lists_controller_spec.rb @@ -44,6 +44,12 @@ ) expect(controller.castle).to have_received(:get_all_lists) end + + it 'renders the Castle activity panel with the call result' do + expect(response.body).to include('Castle activity') + expect(response.body).to include('/lists') + expect(response.body).to include('Response from Castle') + end end context 'when Castle raises' do diff --git a/spec/controllers/users/password_resets_controller_spec.rb b/spec/controllers/users/password_resets_controller_spec.rb index c52ed32..9ce4c35 100644 --- a/spec/controllers/users/password_resets_controller_spec.rb +++ b/spec/controllers/users/password_resets_controller_spec.rb @@ -42,7 +42,7 @@ type: '$password_reset', status: '$succeeded', request_token: nil, - user: { id: user.id, email: user.email } + user: { id: user.id.to_s, email: user.email } ) end end diff --git a/spec/controllers/users/profiles_controller_spec.rb b/spec/controllers/users/profiles_controller_spec.rb index f8dbee7..653cf8a 100644 --- a/spec/controllers/users/profiles_controller_spec.rb +++ b/spec/controllers/users/profiles_controller_spec.rb @@ -41,7 +41,7 @@ type: '$profile_update', status: '$failed', request_token: nil, - user: { id: controller.current_user.id, email: controller.current_user.email } + user: { id: controller.current_user.id.to_s, email: controller.current_user.email } } end @@ -62,7 +62,7 @@ type: '$profile_update', status: '$succeeded', request_token: nil, - user: { id: controller.current_user.id, email: controller.current_user.email } + user: { id: controller.current_user.id.to_s, email: controller.current_user.email } } end @@ -73,6 +73,10 @@ it { expect(response).to redirect_to root_path } it { expect(controller.castle).to have_received(:log).with(log_expected_data) } + + it 'records the profile update for the results panel' do + expect(flash[:castle_results].to_a.first).to include('endpoint' => 'log') + end end context 'when Castle raises while logging' do diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 86b565f..6f1fabb 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -53,6 +53,10 @@ it { expect(response).to redirect_to new_user_session_path } it { expect(flash['error']).to eq I18n.t('users.sessions.create.access_denied') } it { expect(controller.castle).not_to have_received(:risk) } + + it 'records the filter verdict for the results panel' do + expect(flash[:castle_results].to_a.first).to include('endpoint' => 'filter') + end end context 'when login succeeded' do @@ -69,7 +73,7 @@ type: '$login', status: '$succeeded', request_token: nil, - user: { id: user.id, email: user.email } + user: { id: user.id.to_s, email: user.email } } end @@ -85,6 +89,15 @@ it { expect(response).to redirect_to root_path } it { expect(controller.castle).to have_received(:filter).with(filter_args) } it { expect(controller.castle).to have_received(:risk).with(risk_args) } + + it 'records the filter then risk calls for the results panel' do + endpoints = flash[:castle_results].to_a.map { |entry| entry['endpoint'] } + expect(endpoints).to eq(%w[filter risk]) + end + + it 'records the risk response for display' do + expect(flash[:castle_results].to_a.last['response']).to include('policy' => { 'action' => 'allow' }) + end end context 'when user challenged' do @@ -101,6 +114,10 @@ it { expect(response).to redirect_to new_user_session_path } it { expect(flash['error']).to eq error_message } it { expect(controller.castle).to have_received(:risk).with(risk_args) } + + it 'records the denied risk verdict for the results panel' do + expect(flash[:castle_results].to_a.last['response']).to include('policy' => { 'action' => 'deny' }) + end end end @@ -120,7 +137,7 @@ describe 'DELETE destroy' do with_user - let(:log_args) { { type: '$logout', status: '$succeeded', request_token: nil, user: { id: user.id } } } + let(:log_args) { { type: '$logout', status: '$succeeded', request_token: nil, user: { id: user.id.to_s } } } before do allow(controller.castle).to receive(:log) @@ -130,5 +147,9 @@ it { expect(flash[:notice]).to eq I18n.t('devise.sessions.signed_out') } it { expect(response).to redirect_to root_path } it { expect(controller.castle).to have_received(:log).with(log_args) } + + it 'records the logout for the results panel' do + expect(flash[:castle_results].to_a.first).to include('endpoint' => 'log') + end end end From 5e971aebd009d2ebe84e294488c0e93fe4653423 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Thu, 11 Jun 2026 14:24:34 +0200 Subject: [PATCH 2/4] Bump puma to 7.2.1 and net-imap to 0.6.4.1 Raise the puma requirement to ~> 7.2 and update the lockfile to puma 7.2.1 and net-imap 0.6.4.1. Also bump the remaining gems within the existing constraints: msgpack 1.8.3, sqlite3 2.9.5, websocket-driver 0.8.1, web-console 4.3.0 and rspec-rails 8.0.4. --- Gemfile | 2 +- Gemfile.lock | 41 ++++++++++++++++++++--------------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/Gemfile b/Gemfile index 5809d89..c6757ff 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index dad80fd..da54e3f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 From f4bbaf3b22e83fb9e595cc35c70fa94339d3d263 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Thu, 11 Jun 2026 14:30:05 +0200 Subject: [PATCH 3/4] Add demo login quick-fill buttons and seeded demo user Mirror the Node/Python/PHP example apps by adding "valid user + pw", "valid user, bad pw" and "invalid username" quick-fill buttons to the sign-in page. Credentials are centralized in DemoAccount (env-overridable, Devise-valid defaults) and used by db/seeds.rb to seed the Clark Kent demo user so the "valid user + pw" flow actually signs in. --- .env.example | 7 +++++ app/models/demo_account.rb | 38 ++++++++++++++++++++++++++ app/views/users/sessions/new.html.haml | 28 +++++++++++++++++++ db/seeds.rb | 6 ++++ 4 files changed, 79 insertions(+) create mode 100644 app/models/demo_account.rb create mode 100644 db/seeds.rb diff --git a/.env.example b/.env.example index b219601..bde5edb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/models/demo_account.rb b/app/models/demo_account.rb new file mode 100644 index 0000000..2c1002e --- /dev/null +++ b/app/models/demo_account.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Credentials for the pre-seeded demo user, mirroring the fixture used by the +# other Castle example apps. Sourced from the environment so they can be +# overridden, with Devise-valid defaults (the password must be at least 6 +# characters for Devise's :validatable module). +module DemoAccount + module_function + + def email + ENV.fetch('valid_username', 'clark.kent@dailyplanet.com') + end + + def name + ENV.fetch('valid_name', 'Clark Kent') + end + + def password + ENV.fetch('valid_password', 'castle1234') + end + + # A password that does not match the demo user, for the "valid user, bad pw" + # quick-fill on the login page. + def invalid_password + ENV.fetch('invalid_password', 'qwerty') + end + + # Creates (or refreshes) the demo user so the "valid user + pw" quick-fill on + # the login page actually signs in. + def seed! + user = User.find_or_initialize_by(email: email) + user.name = name + user.password = password + user.password_confirmation = password + user.save! + user + end +end diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml index 418ac5c..29c5662 100644 --- a/app/views/users/sessions/new.html.haml +++ b/app/views/users/sessions/new.html.haml @@ -1,5 +1,10 @@ %h2.mb-4= t('.title') +.btn-row.mb-4{ style: 'margin-top:0' } + %button.btn.btn-ghost{ type: 'button', onclick: "fillForm('valid')" } valid user + pw + %button.btn.btn-ghost{ type: 'button', onclick: "fillForm('bad_pw')" } valid user, bad pw + %button.btn.btn-ghost{ type: 'button', onclick: "fillForm('bad_user')" } invalid username + = simple_form_for(resource, as: resource_name, url: session_path(resource_name), html: { data: { castle: true } }) do |f| .form-inputs = f.input :email, required: false, autofocus: true @@ -8,3 +13,26 @@ = f.button :submit, t('.button'), class: 'btn btn-primary w-full' = render 'users/shared/links' + +:javascript + (function () { + var VALID_USER = "#{j DemoAccount.email}"; + var VALID_PW = "#{j DemoAccount.password}"; + var BAD_PW = "#{j DemoAccount.invalid_password}"; + + window.fillForm = function (state) { + var email = document.getElementById("user_email"); + var password = document.getElementById("user_password"); + if (!email || !password) return; + if (state === "valid") { + email.value = VALID_USER; + password.value = VALID_PW; + } else if (state === "bad_pw") { + email.value = VALID_USER; + password.value = BAD_PW; + } else { + email.value = "invalid_user@abc.com"; + password.value = BAD_PW; + } + }; + })(); diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..b9b3cbd --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Seeds the demo user that the login page's "valid user + pw" quick-fill signs +# in as. Safe to run repeatedly. +user = DemoAccount.seed! +puts "Seeded demo user: #{user.email}" From f3e9d5dfa77b15d84342760e6b3d9a1a3eb5885f Mon Sep 17 00:00:00 2001 From: Bartosz Date: Thu, 11 Jun 2026 14:46:46 +0200 Subject: [PATCH 4/4] Add demo quick-fill buttons to the sign-up page Mirror the other example apps with "new user" (lois.lane@dailyplanet.com) and "existing email" (the seeded clark.kent@dailyplanet.com) quick-fill buttons, demonstrating $registration / $attempted vs / $failed. --- app/views/users/registrations/new.html.haml | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/views/users/registrations/new.html.haml b/app/views/users/registrations/new.html.haml index 2fe9fa7..f0354d5 100644 --- a/app/views/users/registrations/new.html.haml +++ b/app/views/users/registrations/new.html.haml @@ -1,5 +1,9 @@ %h2.mb-4 Sign up +.btn-row.mb-4{ style: 'margin-top:0' } + %button.btn.btn-ghost{ type: 'button', onclick: "fillForm('new')" } new user + %button.btn.btn-ghost{ type: 'button', onclick: "fillForm('existing')" } existing email + = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { data: { castle: true } }) do |f| = f.error_notification .form-inputs @@ -11,3 +15,23 @@ = f.button :submit, t('.button'), class: 'btn btn-primary w-full' = render "users/shared/links" + +:javascript + (function () { + var VALID_USER = "#{j DemoAccount.email}"; + var VALID_PW = "#{j DemoAccount.password}"; + + window.fillForm = function (state) { + var email = document.getElementById("user_email"); + var password = document.getElementById("user_password"); + var confirmation = document.getElementById("user_password_confirmation"); + if (!email || !password) return; + if (state === "existing") { + email.value = VALID_USER; + } else { + email.value = "lois.lane@dailyplanet.com"; + } + password.value = VALID_PW; + if (confirmation) confirmation.value = VALID_PW; + }; + })();