list() {
+ return new ArrayList<>(entries);
+ }
+
+ public static final class Entry {
+ private final int id;
+ private final String receivedAt;
+ private final String body;
+
+ Entry(int id, String receivedAt, String body) {
+ this.id = id;
+ this.receivedAt = receivedAt;
+ this.body = body;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getReceivedAt() {
+ return receivedAt;
+ }
+
+ public String getBody() {
+ return body;
+ }
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..a48c2c6
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,3 @@
+server.port=${PORT:4009}
+spring.thymeleaf.cache=false
+spring.application.name=castle-java-example
diff --git a/src/main/resources/castle_sdk.properties b/src/main/resources/castle_sdk.properties
deleted file mode 100644
index b43d82a..0000000
--- a/src/main/resources/castle_sdk.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-# This setting overrides the default value for blacklisted headers.
-# Neither the Cookie nor the Connection headers will be sent in the context of API calls.
-black_list=Cookie,Connection
-log_http=true
-# The rest of the setting will use default values, when there are some.
diff --git a/src/main/resources/templates/account.html b/src/main/resources/templates/account.html
new file mode 100644
index 0000000..8b07b8c
--- /dev/null
+++ b/src/main/resources/templates/account.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ Signed in as user. These actions run once a user is authenticated.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The post-login actions each mint a fresh request token from castle.js:
+
+ - profile update →
$profile_update sent to /risk.
+ - custom event →
Castle.custom() in the browser.
+ - logout →
$logout via the non-blocking /log endpoint.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/base.html b/src/main/resources/templates/base.html
new file mode 100644
index 0000000..eba5a21
--- /dev/null
+++ b/src/main/resources/templates/base.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ Castle workflows
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/demo.html b/src/main/resources/templates/demo.html
new file mode 100644
index 0000000..a992233
--- /dev/null
+++ b/src/main/resources/templates/demo.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+ castle-java
+ Castle workflows demo
+ A small Spring Boot app showing how to integrate the Castle Java SDK.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html
new file mode 100644
index 0000000..5c02374
--- /dev/null
+++ b/src/main/resources/templates/error.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+ 404
+ Page not found
+ Sorry, we couldn't load that URL.
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/events.html b/src/main/resources/templates/events.html
new file mode 100644
index 0000000..536caac
--- /dev/null
+++ b/src/main/resources/templates/events.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The Events API lets you inspect the event schema and query stored events. This demo calls eventsSchema and then queryEvents for the given type.
+ A valid Castle API secret is required for these calls to succeed.
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/lists.html b/src/main/resources/templates/lists.html
new file mode 100644
index 0000000..735dfdb
--- /dev/null
+++ b/src/main/resources/templates/lists.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The Lists API lets you manage allow/block lists programmatically. This demo calls createList and then getAllLists.
+ A valid Castle API secret is required for this call to succeed.
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html
new file mode 100644
index 0000000..b7264d7
--- /dev/null
+++ b/src/main/resources/templates/login.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A login reuses one request token across a two-step sequence:
+
+ - the attempt is always filtered first →
$login / $attempted sent to /filter (anonymous, so the email goes in params).
+ - valid username + valid password →
$login / $succeeded sent to /risk; act on the verdict (allow, challenge, deny).
+ - wrong password / unknown user →
$login / $failed sent to /filter.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/password_reset.html b/src/main/resources/templates/password_reset.html
new file mode 100644
index 0000000..73ba096
--- /dev/null
+++ b/src/main/resources/templates/password_reset.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This demo records the password-reset event with the non-blocking /log endpoint, which stores the event without returning a verdict.
+ Assume the user already passed your reset challenge (e.g. an emailed OTP). Enter a value different from the valid password to send $password_reset / $succeeded, or the valid password to send $password_reset / $failed. (The password is not actually changed.)
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/privacy.html b/src/main/resources/templates/privacy.html
new file mode 100644
index 0000000..315db65
--- /dev/null
+++ b/src/main/resources/templates/privacy.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The Privacy API helps you honor GDPR/CCPA requests via requestUserData and a delete call to the privacy endpoint.
+ A valid Castle API secret is required for these calls to succeed.
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html
new file mode 100644
index 0000000..225f43f
--- /dev/null
+++ b/src/main/resources/templates/signup.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A registration is evaluated before the account exists, so it is anonymous activity sent to /filter with the form params:
+
+ - a new email →
$registration / $attempted; act on the verdict (allow, challenge, deny) before creating the account.
+ - an email that already exists →
$registration / $failed (resolved to the existing user via matching_user_id).
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/webhooks.html b/src/main/resources/templates/webhooks.html
new file mode 100644
index 0000000..5fb5214
--- /dev/null
+++ b/src/main/resources/templates/webhooks.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ This page lists the most recent webhooks Castle has delivered to this app. Each one is signature-verified before it is stored.
+
+
+
+
+
+
+
+
+
+
+
+ No webhooks received yet.
+
+
+
+ Point a webhook at /webhooks/castle from the Castle dashboard (Settings → Webhooks). Incoming requests are verified with verifyWebhookSignature against the X-Castle-Signature header; anything that fails verification gets a 404.
+ Because this demo runs on localhost, Castle needs a public tunnel (e.g. ngrok) to reach the receiver.
+
+
+
+
+
diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml
deleted file mode 100644
index b673d91..0000000
--- a/src/main/webapp/WEB-INF/web.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- Castle Java example
-
- io.castle.example.SetupListener
-
-
diff --git a/src/main/webapp/_castle_script.jsp b/src/main/webapp/_castle_script.jsp
deleted file mode 100644
index 4c674ab..0000000
--- a/src/main/webapp/_castle_script.jsp
+++ /dev/null
@@ -1,4 +0,0 @@
-<%@ page import="io.castle.client.Castle" %>
-
-(function(e,t,n,r){function i(e,n){e=t.createElement("script");e.async=1;e.src=r;n=t.getElementsByTagName("script")[0];n.parentNode.insertBefore(e,n)}e[n]=e[n]||function(){(e[n].q=e[n].q||[]).push(arguments)};e.attachEvent?e.attachEvent("onload",i):e.addEventListener("load",i,false)})(window,document,"_castle","//d2t77mnxyo7adj.cloudfront.net/v1/c.js")
-_castle('setAppId', '<%= Castle.instance().getSdkConfiguration().getCastleAppId() %>');
\ No newline at end of file
diff --git a/src/main/webapp/authentication_error.jsp b/src/main/webapp/authentication_error.jsp
deleted file mode 100644
index 71ad714..0000000
--- a/src/main/webapp/authentication_error.jsp
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Authentication Error
-
-
-Error! Please try to log-in again.
-
-
-
\ No newline at end of file
diff --git a/src/main/webapp/challenge.jsp b/src/main/webapp/challenge.jsp
deleted file mode 100644
index 8ce5938..0000000
--- a/src/main/webapp/challenge.jsp
+++ /dev/null
@@ -1,27 +0,0 @@
-<%@ page import="java.io.IOException" %>
-<%@ page import="io.castle.client.Castle" %>
-
-<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
-
-
-
-
- Authentication Challenge
-
-
-This is a page for the challenge. Please choose the correct button.
-
-
-
-
-
\ No newline at end of file
diff --git a/src/main/webapp/email_change_form.jsp b/src/main/webapp/email_change_form.jsp
deleted file mode 100644
index b650800..0000000
--- a/src/main/webapp/email_change_form.jsp
+++ /dev/null
@@ -1,59 +0,0 @@
-<%@ page import="io.castle.client.Castle" %>
-
-<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
-
-
-
-
-
-
- Update Your Email Account
-
-
- Update your email account
-
-
-
-
-
-
-
-
- <% response.sendError(403); %>
-
-
-
diff --git a/src/main/webapp/email_change_request_succeeded.jsp b/src/main/webapp/email_change_request_succeeded.jsp
deleted file mode 100644
index 242b9d0..0000000
--- a/src/main/webapp/email_change_request_succeeded.jsp
+++ /dev/null
@@ -1,34 +0,0 @@
-<%@ page import="io.castle.client.Castle" %>
-
-<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
-
-
-
-
-
-
- Email Update
-
-
-
-We have sent you a message to the email address you specified. Did you receive it?
-
-
-
-
-
- <% response.sendError(403); %>
-
-
diff --git a/src/main/webapp/email_update_success.jsp b/src/main/webapp/email_update_success.jsp
deleted file mode 100644
index 5c980c4..0000000
--- a/src/main/webapp/email_update_success.jsp
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Email Update Success
-
-
-Your email has been successfully updated! Please log-in again.
-
-
-
\ No newline at end of file
diff --git a/src/main/webapp/forgot_password.jsp b/src/main/webapp/forgot_password.jsp
deleted file mode 100644
index 473e364..0000000
--- a/src/main/webapp/forgot_password.jsp
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
- Reset Your Password
-
-
-We will send you a link to the email address linked to your account.
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp
deleted file mode 100644
index 6e7f89f..0000000
--- a/src/main/webapp/index.jsp
+++ /dev/null
@@ -1,87 +0,0 @@
-<%@ page import="io.castle.client.Castle" %>
-
-<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
-
-
-
-
-
-
- Home
-
-
- Welcome to Castle World
-
-
These are your account details:
- id:
- Login:
Change
- Name:
- Lastname:
-
-
-
-
-
-
-
-
-
-
-
-
-
- Home
-
-
- Welcome to Castle World
-
-
-
-
Test data
-
The example application contains a built-in list of users with the following logins:
-
- - admin@example.com:admin
- - josh@example.com:anyPassword
-
-
-
-
-
-
-
diff --git a/src/main/webapp/logout_success.jsp b/src/main/webapp/logout_success.jsp
deleted file mode 100644
index 0464599..0000000
--- a/src/main/webapp/logout_success.jsp
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Logout Success
-
-
-You have been logged-out successfully!
-
-
-
\ No newline at end of file
diff --git a/src/main/webapp/password_change_form.jsp b/src/main/webapp/password_change_form.jsp
deleted file mode 100644
index 3740c2e..0000000
--- a/src/main/webapp/password_change_form.jsp
+++ /dev/null
@@ -1,60 +0,0 @@
-
-<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
-
-
-
-
-
-
- Change Your Password
-
-
- Change your password
-
-
-
-
-
-
-
-
- <% response.sendError(403); %>
-
-
-
diff --git a/src/main/webapp/password_reset_form.jsp b/src/main/webapp/password_reset_form.jsp
deleted file mode 100644
index 1475554..0000000
--- a/src/main/webapp/password_reset_form.jsp
+++ /dev/null
@@ -1,51 +0,0 @@
-
-<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
-
-
-
-
-
-
- Password Reset
-
-
- Reset your password
-
-
-
-
-
-
-
-
- <% response.sendError(403); %>
-
-
-
diff --git a/src/main/webapp/password_reset_request_error.jsp b/src/main/webapp/password_reset_request_error.jsp
deleted file mode 100644
index f9d8746..0000000
--- a/src/main/webapp/password_reset_request_error.jsp
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- Password Reset Error
-
-
-There is no account associated to that login in our databases. Please provide a valid account.
-
-
-
\ No newline at end of file
diff --git a/src/main/webapp/password_reset_request_succeeded.jsp b/src/main/webapp/password_reset_request_succeeded.jsp
deleted file mode 100644
index 3fbe9b2..0000000
--- a/src/main/webapp/password_reset_request_succeeded.jsp
+++ /dev/null
@@ -1,18 +0,0 @@
-<%@ page contentType="text/html;charset=UTF-8" language="java" %>
-
-
-
- Reset Password Challenge
-
-
-We have sent you an email with details for resetting your password. Did you receive it?
-
-
-
-
-
diff --git a/src/main/webapp/password_reset_success.jsp b/src/main/webapp/password_reset_success.jsp
deleted file mode 100644
index e453bb3..0000000
--- a/src/main/webapp/password_reset_success.jsp
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Password Update Success
-
-
-Your password has been successfully updated! Please log-in again.
-
-
-
\ No newline at end of file
diff --git a/src/tailwind.css b/src/tailwind.css
new file mode 100644
index 0000000..5386b11
--- /dev/null
+++ b/src/tailwind.css
@@ -0,0 +1,244 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ @apply min-h-screen bg-bg font-sans text-[15px] leading-relaxed text-ink antialiased;
+ background-image: radial-gradient(
+ 1200px 600px at 80% -10%,
+ rgba(54, 94, 237, 0.12),
+ transparent 60%
+ );
+ }
+
+ a {
+ @apply text-accent no-underline hover:underline;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4 {
+ @apply font-semibold leading-tight;
+ }
+
+ p {
+ @apply mb-3;
+ }
+
+ code {
+ @apply rounded border border-border bg-surface-2 px-1.5 py-0.5 font-mono text-[0.86em];
+ }
+}
+
+/*
+ * Component classes. Authored outside @layer so they are always emitted even
+ * when the selector only appears in JS-generated markup (badge/json).
+ */
+
+.navbar {
+ @apply sticky top-0 z-50 flex flex-wrap items-center gap-6 border-b border-border px-6 py-3.5;
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(10px);
+}
+
+.brand {
+ @apply flex items-center gap-2 text-[1.05rem] font-bold text-ink hover:no-underline;
+}
+
+.brand-logo {
+ @apply h-[1.4rem] w-[1.4rem] shrink-0 text-accent;
+ filter: drop-shadow(0 0 8px rgba(54, 94, 237, 0.35));
+}
+
+.brand-logo-lg {
+ @apply h-12 w-12;
+}
+
+.nav-links {
+ @apply ml-auto flex flex-wrap items-center gap-5;
+}
+
+.nav-links a {
+ @apply text-[0.92rem] text-muted hover:text-ink hover:no-underline;
+}
+
+.tag {
+ @apply rounded-full border border-accent/40 bg-accent/10 px-2 py-0.5 text-xs font-semibold text-accent;
+}
+
+.container-page {
+ @apply mx-auto max-w-[1120px] px-6 pb-16 pt-8;
+}
+
+.card {
+ @apply rounded-xl border border-border bg-surface p-6 shadow-card;
+}
+
+.eyebrow {
+ @apply mb-1.5 text-xs font-bold uppercase tracking-wider text-muted;
+}
+
+.hero {
+ @apply px-4 py-12 text-center;
+}
+
+.feature {
+ @apply block rounded-xl border border-border bg-surface p-5 text-left transition hover:-translate-y-0.5 hover:border-accent hover:no-underline;
+}
+
+.feature h3 {
+ @apply mb-1 text-ink;
+}
+
+.feature p {
+ @apply m-0 text-sm text-muted;
+}
+
+.field {
+ @apply mb-3.5;
+}
+
+.field label {
+ @apply mb-1.5 block text-[0.82rem] font-semibold text-muted;
+}
+
+.input {
+ @apply w-full rounded-lg border border-border bg-bg-soft px-3 py-2.5 font-sans text-[0.95rem] text-ink transition;
+}
+
+.input:focus {
+ @apply border-accent outline-none;
+ box-shadow: 0 0 0 3px rgba(54, 94, 237, 0.14);
+}
+
+.checkbox {
+ @apply mt-1 flex items-center gap-2 text-[0.85rem] text-muted;
+}
+
+.checkbox input {
+ @apply m-0 w-auto;
+}
+
+.form-links {
+ @apply mt-4 flex justify-between gap-4 text-[0.85rem];
+}
+
+.btn {
+ @apply cursor-pointer rounded-lg border border-border bg-surface-2 px-4 py-2.5 font-sans text-[0.92rem] font-semibold text-ink transition hover:border-accent active:translate-y-px;
+}
+
+.btn-primary {
+ @apply border-accent bg-accent text-white hover:bg-accent-hover;
+}
+
+.btn-ghost {
+ @apply bg-transparent;
+}
+
+.btn-row {
+ @apply mt-4 flex flex-wrap gap-2.5;
+}
+
+.meta-list {
+ @apply m-0 list-none p-0;
+}
+
+.meta-list li {
+ @apply flex justify-between gap-4 border-b border-border-soft py-2 text-[0.9rem] last:border-b-0;
+}
+
+.meta-list .k {
+ @apply text-muted;
+}
+
+.meta-list .v {
+ @apply break-all text-right font-mono text-ink;
+}
+
+.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-normal;
+}
+
+.json .k {
+ color: #0550ae;
+}
+
+.json .s {
+ color: #0a7d33;
+}
+
+.json .n {
+ color: #b25000;
+}
+
+.json .b {
+ @apply text-accent;
+}
+
+.json .z {
+ @apply text-muted;
+}
+
+.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;
+ color: #0b1020;
+}
+
+.verdict-challenge {
+ @apply border-challenge/40 bg-challenge/10;
+}
+
+.verdict-challenge .verdict-action {
+ @apply bg-challenge;
+ color: #0b1020;
+}
+
+.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/src/test/java/io/castle/example/CastleExampleApplicationTests.java b/src/test/java/io/castle/example/CastleExampleApplicationTests.java
new file mode 100644
index 0000000..8680f1a
--- /dev/null
+++ b/src/test/java/io/castle/example/CastleExampleApplicationTests.java
@@ -0,0 +1,63 @@
+package io.castle.example;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+class CastleExampleApplicationTests {
+
+ // Matches the castle_api_secret injected for the test JVM (see the
+ // surefire environmentVariables configuration in pom.xml).
+ private static final String API_SECRET = "test_secret";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void contextLoads() {
+ }
+
+ @Test
+ void homePageRenders() throws Exception {
+ mockMvc.perform(get("/")).andExpect(status().isOk());
+ }
+
+ @Test
+ void webhookRejectsInvalidSignature() throws Exception {
+ mockMvc.perform(post("/webhooks/castle")
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("X-Castle-Signature", "not-a-valid-signature")
+ .content("{\"type\":\"$test\"}"))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void webhookAcceptsValidSignature() throws Exception {
+ byte[] body = "{\"type\":\"$test\"}".getBytes(StandardCharsets.UTF_8);
+ mockMvc.perform(post("/webhooks/castle")
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("X-Castle-Signature", sign(body))
+ .content(body))
+ .andExpect(status().isOk());
+ }
+
+ private static String sign(byte[] body) throws Exception {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(API_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ return Base64.getEncoder().encodeToString(mac.doFinal(body));
+ }
+}
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..8b65cb0
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,184 @@
+// Lightweight helpers shared across the Castle demo pages (no jQuery).
+
+async function postJSON(url, data) {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data || {}),
+ });
+ let body;
+ try {
+ body = await res.json();
+ } catch (e) {
+ body = { error: "Server returned a non-JSON response (status " + res.status + ")." };
+ }
+ return body;
+}
+
+// Resolve a Castle request token, falling back gracefully if the browser SDK
+// is unavailable (e.g. no publishable key configured).
+function withRequestToken(callback) {
+ if (window.Castle && typeof Castle.createRequestToken === "function") {
+ Castle.createRequestToken()
+ .then(callback)
+ .catch(function (err) {
+ console.error("Castle.createRequestToken failed", err);
+ callback("");
+ });
+ } else {
+ callback("");
+ }
+}
+
+function syntaxHighlight(obj) {
+ let json = JSON.stringify(obj, null, 2);
+ json = json.replace(/&/g, "&").replace(//g, ">");
+ return json.replace(
+ /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
+ function (match) {
+ let cls = "n";
+ if (/^"/.test(match)) {
+ cls = /:$/.test(match) ? "k" : "s";
+ } else if (/true|false/.test(match)) {
+ cls = "b";
+ } else if (/null/.test(match)) {
+ cls = "z";
+ }
+ return '' + match + "";
+ },
+ );
+}
+
+function clearResults() {
+ const el = document.getElementById("results");
+ if (el) el.innerHTML = "";
+}
+
+function addEndpointBadge(endpoint) {
+ const el = document.getElementById("results");
+ if (!el) return;
+ const wrap = document.createElement("div");
+ wrap.className = "result-block";
+ wrap.innerHTML =
+ 'Castle endpoint
/' + endpoint + "";
+ el.appendChild(wrap);
+}
+
+function addJSONBlock(label, value) {
+ const el = document.getElementById("results");
+ if (!el) return;
+ const wrap = document.createElement("div");
+ wrap.className = "result-block";
+ const lbl = document.createElement("div");
+ lbl.className = "label";
+ lbl.textContent = label;
+ const pre = document.createElement("pre");
+ pre.className = "json";
+ pre.innerHTML = syntaxHighlight(value);
+ wrap.appendChild(lbl);
+ wrap.appendChild(pre);
+ el.appendChild(wrap);
+}
+
+function showResultsCard() {
+ const card = document.getElementById("results-card");
+ if (card) card.classList.remove("hidden");
+}
+
+// Render the headline verdict (allow / challenge / deny) plus the risk score
+// and any signals returned by the risk/filter endpoints.
+function addVerdictBanner(result) {
+ const el = document.getElementById("results");
+ if (!el || !result || typeof result !== "object") return;
+
+ const action = result.policy && result.policy.action;
+ const hasScore = typeof result.risk === "number";
+ if (!action && !hasScore) return;
+
+ const wrap = document.createElement("div");
+ wrap.className = "result-block";
+
+ const banner = document.createElement("div");
+ banner.className = "verdict verdict-" + (action || "unknown");
+
+ let html = "";
+ if (action) {
+ html += '' + action + "";
+ }
+ if (hasScore) {
+ html +=
+ 'risk ' +
+ result.risk.toFixed(2) +
+ "";
+ }
+ banner.innerHTML = html;
+ wrap.appendChild(banner);
+
+ const signals = result.signals && Object.keys(result.signals);
+ if (signals && signals.length) {
+ const chips = document.createElement("div");
+ chips.className = "signals";
+ signals.forEach(function (name) {
+ const chip = document.createElement("span");
+ chip.className = "chip";
+ chip.textContent = name;
+ chips.appendChild(chip);
+ });
+ wrap.appendChild(chips);
+ }
+
+ el.appendChild(wrap);
+}
+
+// Standard renderer for the {api_endpoint, payload_to_castle, result} shape
+// returned by the demo backend routes.
+function renderCastleResponse(data) {
+ clearResults();
+ if (data.api_endpoint) addEndpointBadge(data.api_endpoint);
+ addVerdictBanner(data.result);
+ if (data.payload_to_castle) addJSONBlock("Payload sent to Castle", data.payload_to_castle);
+ if (data.result !== undefined && data.result !== null) {
+ addJSONBlock("Response from Castle", data.result);
+ }
+ showResultsCard();
+}
+
+// Renders an ordered sequence of Castle calls (e.g. the login Filter -> Risk
+// flow), one endpoint/verdict/payload/result block per step.
+function renderCastleSteps(steps) {
+ clearResults();
+ (steps || []).forEach(function (step) {
+ if (step.api_endpoint) addEndpointBadge(step.api_endpoint);
+ addVerdictBanner(step.result);
+ if (step.payload_to_castle) addJSONBlock("Payload sent to Castle", step.payload_to_castle);
+ if (step.result !== undefined && step.result !== null) {
+ addJSONBlock("Response from Castle", step.result);
+ }
+ });
+ showResultsCard();
+}
+
+// Tell Castle which page the user is on. Safe no-op if the browser SDK or the
+// publishable key is unavailable.
+function trackPage() {
+ if (window.Castle && typeof Castle.page === "function") {
+ try {
+ Castle.page();
+ } catch (e) {
+ console.error("Castle.page failed", e);
+ }
+ }
+}
+
+// Fire an ad-hoc client-side event (e.g. a button click) to Castle.
+function trackCustomEvent(name) {
+ if (window.Castle && typeof Castle.custom === "function") {
+ try {
+ Castle.custom({ name: name });
+ } catch (e) {
+ console.error("Castle.custom failed", e);
+ }
+ }
+}
+
+document.addEventListener("DOMContentLoaded", trackPage);
diff --git a/static/styles.css b/static/styles.css
new file mode 100644
index 0000000..f9e9230
--- /dev/null
+++ b/static/styles.css
@@ -0,0 +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{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}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-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color: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{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[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));font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:15px;line-height:1.625;color:rgb(15 23 41/var(--tw-text-opacity,1));-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));padding:.125rem .375rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em}.my-5{margin-top:1.25rem;margin-bottom:1.25rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.grid{display:grid}.hidden{display:none}.list-decimal{list-style-type:decimal}.items-start{align-items:flex-start}.justify-center{justify-content:center}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.whitespace-pre-wrap{white-space:pre-wrap}.border-border{--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1))}.pl-5{padding-left:1.25rem}.text-\[0\.8rem\]{font-size:.8rem}.text-\[1\.15rem\]{font-size:1.15rem}.text-\[2\.2rem\]{font-size:2.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{position:sticky;top:0;z-index:50;flex-wrap:wrap;gap:1.5rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding:.875rem 1.5rem;background:hsla(0,0%,100%,.8);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.brand,.navbar{display:flex;align-items:center}.brand{gap:.5rem;font-size:1.05rem;font-weight:700;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.brand:hover{text-decoration-line:none}.brand-logo{height:1.4rem;width:1.4rem;flex-shrink:0;--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{margin-left:auto;display:flex;flex-wrap:wrap;align-items:center;gap:1.25rem}.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}.tag{border-radius:9999px;border-width:1px;border-color:rgba(54,94,237,.4);background-color:rgba(54,94,237,.1);padding:.125rem .5rem;font-size:.75rem;line-height:1rem;font-weight:600;--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}.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{margin-bottom:.375rem;font-size:.75rem;line-height:1rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.hero{padding:3rem 1rem;text-align:center}.feature{display:block;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.25rem;text-align:left;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);transition-duration:.15s}.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}.feature h3{margin-bottom:.25rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.feature p{margin:0;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.field{margin-bottom:.875rem}.field label{margin-bottom:.375rem;display:block;font-size:.82rem;font-weight:600;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.input{width:100%;border-radius:9px;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));padding:.625rem .75rem;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.95rem;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));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);transition-duration:.15s}.input:focus{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1));outline:2px solid transparent;outline-offset:2px;box-shadow:0 0 0 3px rgba(54,94,237,.14)}.checkbox{margin-top:.25rem;display:flex;align-items:center;gap:.5rem;font-size:.85rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.checkbox input{margin:0;width:auto}.form-links{margin-top:1rem;display:flex;justify-content:space-between;gap:1rem;font-size:.85rem}.btn{cursor:pointer;border-radius:9px;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));padding:.625rem 1rem;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:.92rem;font-weight:600;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1));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);transition-duration:.15s}.btn:hover{--tw-border-opacity:1;border-color:rgb(54 94 237/var(--tw-border-opacity,1))}.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-ghost{background-color:transparent}.btn-row{margin-top:1rem;display:flex;flex-wrap:wrap;gap:.625rem}.meta-list{margin:0;list-style-type:none;padding:0}.meta-list li{display:flex;justify-content:space-between;gap:1rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(233 237 245/var(--tw-border-opacity,1));padding-top:.5rem;padding-bottom:.5rem;font-size:.9rem}.meta-list li:last-child{border-bottom-width:0}.meta-list .k{--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.meta-list .v{word-break:break-all;text-align:right;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--tw-text-opacity:1;color:rgb(15 23 41/var(--tw-text-opacity,1))}.result-block{margin-top:1rem}.result-block .label{margin-bottom:.375rem;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.025em;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}pre.json{margin:0;overflow:auto;border-radius:9px;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));padding:1rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.85rem;line-height:1.5}.json .k{color:#0550ae}.json .s{color:#0a7d33}.json .n{color:#b25000}.json .b{color:rgb(54 94 237/var(--tw-text-opacity,1))}.json .b,.json .z{--tw-text-opacity:1}.json .z{color:rgb(91 102 120/var(--tw-text-opacity,1))}.badge{display:inline-block;border-radius:9999px;border-width:1px;--tw-border-opacity:1;border-color:rgb(221 227 238/var(--tw-border-opacity,1));padding:.125rem .5rem;font-size:.75rem;line-height:1rem;font-weight:600}.badge.endpoint{border-color:rgba(54,94,237,.4);background-color:rgba(54,94,237,.1);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--tw-text-opacity:1;color:rgb(54 94 237/var(--tw-text-opacity,1))}.verdict{display:flex;align-items:center;gap:.875rem;border-radius:9px;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));padding:.625rem 1rem}.verdict-action{border-radius:9999px;padding:.25rem .625rem;font-size:.85rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.verdict-score{font-size:.9rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}.verdict-allow{border-color:rgba(22,163,74,.4);background-color:rgba(22,163,74,.1)}.verdict-allow .verdict-action{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1));color:#0b1020}.verdict-challenge{border-color:rgba(245,158,11,.4);background-color:rgba(245,158,11,.1)}.verdict-challenge .verdict-action{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1));color:#0b1020}.verdict-deny{border-color:rgba(220,38,38,.4);background-color:rgba(220,38,38,.1)}.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{margin-top:.625rem;display:flex;flex-wrap:wrap;gap:.375rem}.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));padding:.125rem .5rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.75rem;line-height:1rem;--tw-text-opacity:1;color:rgb(91 102 120/var(--tw-text-opacity,1))}@media (min-width:768px){.md\:grid-cols-\[1\.3fr_1fr\]{grid-template-columns:1.3fr 1fr}}
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..46d07c8
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,37 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ // Scan the templates and the browser helpers (which build result markup) so
+ // every utility used in markup or JS strings is generated.
+ content: ['./src/main/resources/templates/**/*.html', './static/**/*.js'],
+ theme: {
+ extend: {
+ colors: {
+ bg: '#f6f8fc',
+ 'bg-soft': '#eef2f9',
+ surface: '#ffffff',
+ 'surface-2': '#eef2fb',
+ border: '#dde3ee',
+ 'border-soft': '#e9edf5',
+ ink: '#0f1729',
+ muted: '#5b6678',
+ accent: '#365eed',
+ 'accent-hover': '#2a4ed1',
+ success: '#16a34a',
+ challenge: '#f59e0b',
+ danger: '#dc2626',
+ },
+ fontFamily: {
+ sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
+ mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
+ },
+ borderRadius: {
+ xl: '14px',
+ lg: '9px',
+ },
+ boxShadow: {
+ card: '0 1px 3px rgba(16, 24, 40, 0.06), 0 8px 24px rgba(16, 24, 40, 0.06)',
+ },
+ },
+ },
+ plugins: [],
+};