From 87560f55dea53832b41705582e40022ea41a10e3 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 10 Jun 2026 23:30:37 +0200 Subject: [PATCH 1/4] Add Lists, Events, privacy request and webhook demos Add demo endpoints exercising the SDK directly: /lists-demo (Lists and list items lifecycle), /events-demo (event schema and query), /privacy-demo (privacy data request) and /webhooks (verifies the X-Castle-Signature header against the raw request body). Link them from the home page. Bump the castle-java dependency to 2.2.0 and the app version to 1.5.0. --- README.md | 16 +++++ pom.xml | 4 +- .../io/castle/example/EventsDemoServlet.java | 45 ++++++++++++ .../io/castle/example/ListsDemoServlet.java | 70 +++++++++++++++++++ .../io/castle/example/PrivacyDemoServlet.java | 47 +++++++++++++ .../io/castle/example/WebhookServlet.java | 70 +++++++++++++++++++ src/main/webapp/index.jsp | 18 +++++ 7 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/castle/example/EventsDemoServlet.java create mode 100644 src/main/java/io/castle/example/ListsDemoServlet.java create mode 100644 src/main/java/io/castle/example/PrivacyDemoServlet.java create mode 100644 src/main/java/io/castle/example/WebhookServlet.java diff --git a/README.md b/README.md index fd7caee..dbdbee5 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,19 @@ $ mvn jetty:run Navigate to: http://localhost:8080/ + +API demos +========= + +The home page links to a set of demos exercising the Castle SDK directly: + +* **Lists & list items API** (`/lists-demo`) — creates a list, adds an item, + queries the items, lists all lists and deletes the list. +* **Events API** (`/events-demo`) — fetches the event schema and queries events. +* **Privacy data request** (`/privacy-demo?userId=...`) — requests the data + Castle holds for a user. +* **Received webhooks** (`/webhooks`) — `POST` a Castle webhook with a valid + `X-Castle-Signature` header to have it verified against the raw request body + and listed on the page. + +These demos require `castle-java` 2.2.0. diff --git a/pom.xml b/pom.xml index 617bc1a..7f64fc2 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ io.castle.example castle-example war - 1.4.0 + 1.5.0 Castle Java JDK Example https://github.com/castle/castle-java-example @@ -27,7 +27,7 @@ io.castle castle-java - 1.4.0 + 2.2.0 diff --git a/src/main/java/io/castle/example/EventsDemoServlet.java b/src/main/java/io/castle/example/EventsDemoServlet.java new file mode 100644 index 0000000..4755a79 --- /dev/null +++ b/src/main/java/io/castle/example/EventsDemoServlet.java @@ -0,0 +1,45 @@ +package io.castle.example; + +import com.google.common.collect.ImmutableMap; +import io.castle.client.Castle; +import io.castle.client.api.CastleApi; +import io.castle.client.model.CastleResponse; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +/** + * Demonstrates the Events API (schema and query). + */ +@WebServlet("/events-demo") +public class EventsDemoServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("text/html; charset=utf-8"); + PrintWriter out = resp.getWriter(); + out.println("Events API demo"); + out.println("

Events API demo

← Home

"); + + CastleApi client = Castle.instance().client(); + + try { + CastleResponse schema = client.eventsSchema(); + ListsDemoServlet.render(out, "eventsSchema", schema); + + CastleResponse query = client.queryEvents(ImmutableMap.builder() + .put("filters", ImmutableMap.of()) + .build()); + ListsDemoServlet.render(out, "queryEvents", query); + } catch (Exception e) { + out.println("
Error: " + e.getMessage() + "
"); + } + + out.println(""); + } +} diff --git a/src/main/java/io/castle/example/ListsDemoServlet.java b/src/main/java/io/castle/example/ListsDemoServlet.java new file mode 100644 index 0000000..d5b7c73 --- /dev/null +++ b/src/main/java/io/castle/example/ListsDemoServlet.java @@ -0,0 +1,70 @@ +package io.castle.example; + +import com.google.common.collect.ImmutableMap; +import io.castle.client.Castle; +import io.castle.client.api.CastleApi; +import io.castle.client.model.CastleResponse; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +/** + * Demonstrates the Lists and List items API end to end: + * create a list, add an item, query the items, list all lists and delete the list. + */ +@WebServlet("/lists-demo") +public class ListsDemoServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("text/html; charset=utf-8"); + PrintWriter out = resp.getWriter(); + out.println("Lists API demo"); + out.println("

Lists API demo

← Home

"); + + CastleApi client = Castle.instance().client(); + + try { + CastleResponse created = client.createList(ImmutableMap.builder() + .put("name", "Example trusted IPs") + .put("description", "Created by the castle-java example app") + .put("color", "$green") + .put("primary_field", "context.ip") + .build()); + render(out, "createList", created); + + String listId = created.json().getAsJsonObject().get("id").getAsString(); + + CastleResponse item = client.createListItem(listId, ImmutableMap.builder() + .put("primary_value", "1.2.3.4") + .put("comment", "added from the example app") + .build()); + render(out, "createListItem", item); + + CastleResponse items = client.queryListItems(listId, ImmutableMap.builder() + .put("filters", ImmutableMap.of()) + .build()); + render(out, "queryListItems", items); + + CastleResponse all = client.getAllLists(); + render(out, "getAllLists", all); + + CastleResponse deleted = client.deleteList(listId); + render(out, "deleteList", deleted); + } catch (Exception e) { + out.println("
Error: " + e.getMessage() + "
"); + } + + out.println(""); + } + + static void render(PrintWriter out, String label, CastleResponse response) { + out.println("

" + label + " (HTTP " + (response.isSuccessful() ? "2xx" : "error") + ")

"); + out.println("
" + response.json() + "
"); + } +} diff --git a/src/main/java/io/castle/example/PrivacyDemoServlet.java b/src/main/java/io/castle/example/PrivacyDemoServlet.java new file mode 100644 index 0000000..3d9cbcc --- /dev/null +++ b/src/main/java/io/castle/example/PrivacyDemoServlet.java @@ -0,0 +1,47 @@ +package io.castle.example; + +import com.google.common.collect.ImmutableMap; +import io.castle.client.Castle; +import io.castle.client.api.CastleApi; +import io.castle.client.model.CastleResponse; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +/** + * Demonstrates the privacy data-request API. + */ +@WebServlet("/privacy-demo") +public class PrivacyDemoServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("text/html; charset=utf-8"); + PrintWriter out = resp.getWriter(); + out.println("Privacy API demo"); + out.println("

Privacy API demo

← Home

"); + + String userId = req.getParameter("userId"); + if (userId == null || userId.isEmpty()) { + userId = "1"; + } + + CastleApi client = Castle.instance().client(); + + try { + CastleResponse response = client.requestUserData(ImmutableMap.builder() + .put("user_id", userId) + .build()); + ListsDemoServlet.render(out, "requestUserData(" + userId + ")", response); + } catch (Exception e) { + out.println("
Error: " + e.getMessage() + "
"); + } + + out.println(""); + } +} diff --git a/src/main/java/io/castle/example/WebhookServlet.java b/src/main/java/io/castle/example/WebhookServlet.java new file mode 100644 index 0000000..c8de831 --- /dev/null +++ b/src/main/java/io/castle/example/WebhookServlet.java @@ -0,0 +1,70 @@ +package io.castle.example; + +import io.castle.client.Castle; + +import javax.servlet.ServletException; +import javax.servlet.ServletInputStream; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Receives Castle webhooks and verifies the {@code X-Castle-Signature} header + * against the raw request body before trusting the payload. + */ +@WebServlet("/webhooks") +public class WebhookServlet extends HttpServlet { + + private static final List RECEIVED = new CopyOnWriteArrayList(); + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + byte[] body = readBody(req); + + boolean valid = Castle.instance().verifyWebhookSignature(req, body); + + if (!valid) { + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + resp.getWriter().println("invalid signature"); + return; + } + + RECEIVED.add(0, new String(body, "UTF-8")); + resp.setStatus(HttpServletResponse.SC_OK); + resp.getWriter().println("ok"); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("text/html; charset=utf-8"); + PrintWriter out = resp.getWriter(); + out.println("Webhooks"); + out.println("

Received webhooks

← Home

"); + out.println("

POST a Castle webhook to /webhooks with a valid " + + "X-Castle-Signature header to see it verified and listed here.

"); + if (RECEIVED.isEmpty()) { + out.println("

No verified webhooks received yet.

"); + } + for (String payload : RECEIVED) { + out.println("
" + payload + "
"); + } + out.println(""); + } + + private byte[] readBody(HttpServletRequest req) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + ServletInputStream in = req.getInputStream(); + byte[] chunk = new byte[4096]; + int read; + while ((read = in.read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + return buffer.toByteArray(); + } +} diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp index 6e7f89f..fb91522 100644 --- a/src/main/webapp/index.jsp +++ b/src/main/webapp/index.jsp @@ -31,6 +31,15 @@
Change your password
+
+

API demos

+ +
@@ -72,6 +81,15 @@ +

Test data

The example application contains a built-in list of users with the following logins:

From 3d31b558856ff6daf123cf810ab12e127f254641 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Thu, 11 Jun 2026 23:11:48 +0200 Subject: [PATCH 2/4] Rebuild the example as a Spring Boot app on castle-java 3.0.0 Replace the JSP/servlet authenticate + track app with a Thymeleaf Spring Boot 3 app demonstrating the modern Castle surface: the filter/risk/log lifecycle (sign up, login, account, password reset), the typed Lists API, privacy request/delete, and webhook signature verification. Target the castle-java 3.0.0 SDK on jakarta.servlet and run CI on a JDK 17/21/25 matrix, building the SDK from source until 3.0.0 is published. --- .circleci/config.yml | 29 - .dockerignore | 5 + .env_example | 8 + .github/workflows/ci.yml | 41 + .gitignore | 3 + .project | 23 - Dockerfile | 35 + README.md | 103 +- package-lock.json | 1038 +++++++++++++++++ package.json | 21 + pom.xml | 53 +- .../example/CastleExampleApplication.java | 12 + .../io/castle/example/ChallengeServlet.java | 41 - .../example/EmailChangeRequestServlet.java | 46 - .../io/castle/example/EmailChangeServlet.java | 52 - .../io/castle/example/EventsDemoServlet.java | 45 - .../io/castle/example/ListsDemoServlet.java | 70 -- .../java/io/castle/example/LoginServlet.java | 102 -- .../java/io/castle/example/LogoutServlet.java | 32 - .../castle/example/PasswordChangeServlet.java | 54 - .../example/PasswordResetRequiredServlet.java | 53 - .../castle/example/PasswordResetServlet.java | 53 - .../io/castle/example/PrivacyDemoServlet.java | 47 - .../java/io/castle/example/SetupListener.java | 30 - .../io/castle/example/WebhookServlet.java | 70 -- .../castle/example/config/CastleConfig.java | 40 + .../java/io/castle/example/config/Demo.java | 36 + .../io/castle/example/config/DemoEnv.java | 80 ++ .../java/io/castle/example/config/Demos.java | 43 + .../io/castle/example/config/WebConfig.java | 22 + .../io/castle/example/model/TestUser.java | 49 - .../model/UserAuthenticationBackend.java | 57 - .../io/castle/example/web/CastleSupport.java | 54 + .../io/castle/example/web/DemoController.java | 393 +++++++ .../io/castle/example/web/PageController.java | 82 ++ .../io/castle/example/web/WebhookStore.java | 56 + src/main/resources/application.properties | 3 + src/main/resources/castle_sdk.properties | 5 - src/main/resources/templates/account.html | 81 ++ src/main/resources/templates/base.html | 44 + src/main/resources/templates/demo.html | 71 ++ src/main/resources/templates/error.html | 21 + src/main/resources/templates/lists.html | 45 + src/main/resources/templates/login.html | 91 ++ .../resources/templates/password_reset.html | 47 + src/main/resources/templates/privacy.html | 42 + src/main/resources/templates/signup.html | 89 ++ src/main/resources/templates/webhooks.html | 36 + src/main/webapp/WEB-INF/web.xml | 12 - src/main/webapp/_castle_script.jsp | 4 - src/main/webapp/authentication_error.jsp | 14 - src/main/webapp/challenge.jsp | 27 - src/main/webapp/email_change_form.jsp | 59 - .../webapp/email_change_request_succeeded.jsp | 34 - src/main/webapp/email_update_success.jsp | 14 - src/main/webapp/forgot_password.jsp | 26 - src/main/webapp/index.jsp | 105 -- src/main/webapp/logout_success.jsp | 14 - src/main/webapp/password_change_form.jsp | 60 - src/main/webapp/password_reset_form.jsp | 51 - .../webapp/password_reset_request_error.jsp | 15 - .../password_reset_request_succeeded.jsp | 18 - src/main/webapp/password_reset_success.jsp | 14 - src/tailwind.css | 244 ++++ static/app.js | 184 +++ static/styles.css | 1 + tailwind.config.js | 37 + 67 files changed, 3116 insertions(+), 1370 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .dockerignore create mode 100644 .env_example create mode 100644 .github/workflows/ci.yml delete mode 100644 .project create mode 100644 Dockerfile create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/main/java/io/castle/example/CastleExampleApplication.java delete mode 100644 src/main/java/io/castle/example/ChallengeServlet.java delete mode 100644 src/main/java/io/castle/example/EmailChangeRequestServlet.java delete mode 100644 src/main/java/io/castle/example/EmailChangeServlet.java delete mode 100644 src/main/java/io/castle/example/EventsDemoServlet.java delete mode 100644 src/main/java/io/castle/example/ListsDemoServlet.java delete mode 100644 src/main/java/io/castle/example/LoginServlet.java delete mode 100644 src/main/java/io/castle/example/LogoutServlet.java delete mode 100644 src/main/java/io/castle/example/PasswordChangeServlet.java delete mode 100644 src/main/java/io/castle/example/PasswordResetRequiredServlet.java delete mode 100644 src/main/java/io/castle/example/PasswordResetServlet.java delete mode 100644 src/main/java/io/castle/example/PrivacyDemoServlet.java delete mode 100644 src/main/java/io/castle/example/SetupListener.java delete mode 100644 src/main/java/io/castle/example/WebhookServlet.java create mode 100644 src/main/java/io/castle/example/config/CastleConfig.java create mode 100644 src/main/java/io/castle/example/config/Demo.java create mode 100644 src/main/java/io/castle/example/config/DemoEnv.java create mode 100644 src/main/java/io/castle/example/config/Demos.java create mode 100644 src/main/java/io/castle/example/config/WebConfig.java delete mode 100644 src/main/java/io/castle/example/model/TestUser.java delete mode 100644 src/main/java/io/castle/example/model/UserAuthenticationBackend.java create mode 100644 src/main/java/io/castle/example/web/CastleSupport.java create mode 100644 src/main/java/io/castle/example/web/DemoController.java create mode 100644 src/main/java/io/castle/example/web/PageController.java create mode 100644 src/main/java/io/castle/example/web/WebhookStore.java create mode 100644 src/main/resources/application.properties delete mode 100644 src/main/resources/castle_sdk.properties create mode 100644 src/main/resources/templates/account.html create mode 100644 src/main/resources/templates/base.html create mode 100644 src/main/resources/templates/demo.html create mode 100644 src/main/resources/templates/error.html create mode 100644 src/main/resources/templates/lists.html create mode 100644 src/main/resources/templates/login.html create mode 100644 src/main/resources/templates/password_reset.html create mode 100644 src/main/resources/templates/privacy.html create mode 100644 src/main/resources/templates/signup.html create mode 100644 src/main/resources/templates/webhooks.html delete mode 100644 src/main/webapp/WEB-INF/web.xml delete mode 100644 src/main/webapp/_castle_script.jsp delete mode 100644 src/main/webapp/authentication_error.jsp delete mode 100644 src/main/webapp/challenge.jsp delete mode 100644 src/main/webapp/email_change_form.jsp delete mode 100644 src/main/webapp/email_change_request_succeeded.jsp delete mode 100644 src/main/webapp/email_update_success.jsp delete mode 100644 src/main/webapp/forgot_password.jsp delete mode 100644 src/main/webapp/index.jsp delete mode 100644 src/main/webapp/logout_success.jsp delete mode 100644 src/main/webapp/password_change_form.jsp delete mode 100644 src/main/webapp/password_reset_form.jsp delete mode 100644 src/main/webapp/password_reset_request_error.jsp delete mode 100644 src/main/webapp/password_reset_request_succeeded.jsp delete mode 100644 src/main/webapp/password_reset_success.jsp create mode 100644 src/tailwind.css create mode 100644 static/app.js create mode 100644 static/styles.css create mode 100644 tailwind.config.js diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 87db593..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,29 +0,0 @@ -version: 2 # use CircleCI 2.0 -jobs: # a collection of steps - build: # runs not using Workflows must have a `build` job as entry point - - working_directory: ~/castle-java-example # directory where steps will run - - docker: # run the steps with Docker - - image: circleci/openjdk:8-jdk-browsers # ...with this image as the primary container; this is where all `steps` will run - - steps: # a collection of executable commands - - - checkout # check out source code to working directory - - - restore_cache: # restore the saved cache after the first run or if `pom.xml` has changed - # Read about caching dependencies: https://circleci.com/docs/2.0/caching/ - key: castle-java-example-{{ checksum "pom.xml" }} - - - run: mvn dependency:go-offline # gets the project dependencies - - - save_cache: # saves the project dependencies - paths: - - ~/.m2 - key: castle-java-example-{{ checksum "pom.xml" }} - - - run: mvn package # run the actual tests - - - store_test_results: # uploads the test metadata from the `target/surefire-reports` directory so that it can show up in the CircleCI dashboard. - # Upload test results for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/ - path: target/surefire-reports \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e869507 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +.git +node_modules +target +.DS_Store diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..7e4cc10 --- /dev/null +++ b/.env_example @@ -0,0 +1,8 @@ +# Copy this file to .env and fill in your Castle credentials. +# Grab them from the Castle dashboard: https://dashboard.castle.io (Settings -> API). + +# Publishable key, used by the browser SDK to mint request tokens. +castle_pk= + +# Server-side API secret, used by the Castle Java SDK. +castle_api_secret= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c0ce8f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java-version: ['17', '21', '25'] + steps: + - name: Check out example + uses: actions/checkout@v5 + with: + path: example + + # castle-java 3.0.0 is not yet on Maven Central; build it from source and + # install it into the local repository so the example can resolve it. + - name: Check out castle-java SDK + uses: actions/checkout@v5 + with: + repository: castle/castle-java + ref: develop + path: castle-java + + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.java-version }} + cache: maven + + - name: Install castle-java SDK + run: mvn -B -ntp -f castle-java/pom.xml install -DskipTests -Dmaven.javadoc.skip=true + + - name: Build and test example + run: mvn -B -ntp -f example/pom.xml verify diff --git a/.gitignore b/.gitignore index 5231862..6941f08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.DS_Store +.env .idea *.iml +node_modules target diff --git a/.project b/.project deleted file mode 100644 index 5903088..0000000 --- a/.project +++ /dev/null @@ -1,23 +0,0 @@ - - - castle-java-example - - - - - - org.eclipse.jdt.core.javabuilder - - - - - org.eclipse.m2e.core.maven2Builder - - - - - - org.eclipse.jdt.core.javanature - org.eclipse.m2e.core.maven2Nature - - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a4090a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Fetch the Castle browser SDK from npm and build the Tailwind stylesheet. +FROM node:20-slim AS frontend +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tailwind.config.js ./ +COPY src/tailwind.css ./src/tailwind.css +COPY src/main/resources/templates ./src/main/resources/templates +COPY static ./static +RUN npm run build:css + +# Build the Spring Boot jar. +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /app +COPY pom.xml ./ +RUN mvn -B -q -DskipTests dependency:go-offline +COPY src ./src +RUN mvn -B -q -DskipTests package + +# Final runtime image. +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=build /app/target/castle-example.jar ./app.jar +COPY --from=frontend /app/static ./static +COPY --from=frontend /app/node_modules/@castleio/castle-js/dist ./node_modules/@castleio/castle-js/dist + +ENV location=docker +ENV PORT=80 + +# Only the Castle credentials are needed at runtime (e.g. docker run -e ...); +# the simulated demo user values are baked in as code defaults. + +EXPOSE 80 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index dbdbee5..1e79269 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,94 @@ -# Castle Java Example +# Castle demo application: Java -[![CircleCI](https://circleci.com/gh/castle/castle-java-example.svg?style=svg)](https://circleci.com/gh/castle/castle-java-example) +This project demonstrates key Castle workflows in a small Spring Boot app built +on the [Castle Java SDK](https://github.com/castle/castle-java). The pages are +rendered server-side with Thymeleaf and the JSON endpoints are plain Spring MVC +controllers. -This is an example of integrating Castle with a standard Java web application. +## What's demonstrated -Prerequisites -============== +The app walks through a full user lifecycle. Every action mints a fresh Castle +request token in the browser (`Castle.createRequestToken()`) and forwards it to +the backend, which calls Castle and acts on the verdict. -* Maven -* Java 7+ +- **sign up** – `$registration` to `filter` (anonymous, so the email goes in `params`): `$attempted` for a new email, `$failed` (resolved via `matching_user_id`) for an email that already exists +- **login** – `$login` reusing one request token across two calls: `filter` `$attempted` first, then `risk` `$succeeded` on success or `filter` `$failed` (wrong password / unknown user) +- **account** – post-login actions: profile update (`$profile_update` to `risk`), a custom event (`Castle.custom()`), and logout (`$logout` via the non-blocking `log` endpoint) +- **password reset** – `$password_reset` via the non-blocking `log` endpoint +- **lists** – the Lists API (`createList`, `getAllLists`) +- **privacy** – the Privacy API (`requestUserData` and a delete call to the privacy endpoint) +- **webhooks** – incoming Castle webhooks are signature-verified with `verifyWebhookSignature` (against the `X-Castle-Signature` header) and the most recent payloads are listed -Running the code -================ +## Prerequisites -Set environment variables `CASTLE_SDK_API_SECRET` and `CASTLE_SDK_APP_ID`. +You'll need a Castle account. If you don't have one, start a free trial at +https://castle.io. For local development, use a **sandbox** environment so demo +traffic from `localhost` stays separate from production data — from the Castle +dashboard (Settings → API) grab the sandbox keys: +- your **publishable key** (`castle_pk`) – used by the browser SDK +- your **API secret** (`castle_api_secret`) – used by the backend SDK + +These are the only two values you need to configure. + +## Running locally + +Requires **JDK 17+** and **Maven**. + +```bash +git clone https://github.com/castle/castle-java-example.git +cd castle-java-example ``` -export CASTLE_SDK_API_SECRET=.... -export CASTLE_SDK_APP_ID=... + +The Castle browser SDK and the Tailwind stylesheet are built from npm, so +install the dependencies and build the CSS: + +```bash +npm install +npm run build:css ``` -Run the web server with: +Create your `.env` from the example and fill in your two Castle keys: +```bash +cp .env_example .env ``` -$ mvn jetty:run + +Run the app: + +```bash +mvn spring-boot:run +# Castle Java demo listening on http://localhost:4009 ``` -Navigate to: +The server listens on `:4009` (override with `PORT`). Open +. + +## Running with Docker -http://localhost:8080/ +The bundled `Dockerfile` builds the stylesheet and browser SDK, compiles the +Spring Boot jar and serves the app on port 80. -API demos -========= +```bash +docker build -t castle-java-example . + +docker run -d -p 4009:80 \ + -e castle_pk=YOUR_PUBLISHABLE_KEY \ + -e castle_api_secret=YOUR_API_SECRET \ + castle-java-example +``` -The home page links to a set of demos exercising the Castle SDK directly: +The app will be available at http://127.0.0.1:4009. Point it at a Castle sandbox +environment when running locally. + +## Running the tests + +```bash +mvn test +``` -* **Lists & list items API** (`/lists-demo`) — creates a list, adds an item, - queries the items, lists all lists and deletes the list. -* **Events API** (`/events-demo`) — fetches the event schema and queries events. -* **Privacy data request** (`/privacy-demo?userId=...`) — requests the data - Castle holds for a user. -* **Received webhooks** (`/webhooks`) — `POST` a Castle webhook with a valid - `X-Castle-Signature` header to have it verified against the raw request body - and listed on the page. +## Disclaimer -These demos require `castle-java` 2.2.0. +We're sharing this sample app in the hope that other developers find it +valuable. Although it is not an officially supported sample, we welcome +questions and suggestions at `support@castle.io`. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d58ed57 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1038 @@ +{ + "name": "castle-java-example", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "castle-java-example", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@castleio/castle-js": "^2.8.4" + }, + "devDependencies": { + "tailwindcss": "^3.4.19" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@castleio/castle-js": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@castleio/castle-js/-/castle-js-2.8.4.tgz", + "integrity": "sha512-RV5iEURaNyDpJpmKIPNHlKcU35/4wVAh1xyjDnVzM7sUz0y7UHJocviGBS3dmvipoddgIqMn0CGXSi8Bsy8FNA==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..49d8415 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "castle-java-example", + "version": "2.0.0", + "description": "A small Spring Boot app demonstrating key Castle workflows (sign up, login, account, password reset, lists, privacy, webhooks) on top of the Castle Java SDK.", + "private": false, + "repository": { + "type": "git", + "url": "git+https://github.com/castle/castle-java-example.git" + }, + "license": "MIT", + "scripts": { + "build:css": "tailwindcss -i ./src/tailwind.css -o ./static/styles.css --minify", + "watch:css": "tailwindcss -i ./src/tailwind.css -o ./static/styles.css --watch" + }, + "dependencies": { + "@castleio/castle-js": "^2.8.4" + }, + "devDependencies": { + "tailwindcss": "^3.4.19" + } +} diff --git a/pom.xml b/pom.xml index 7f64fc2..e72647f 100644 --- a/pom.xml +++ b/pom.xml @@ -1,33 +1,53 @@ + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.15 + + + io.castle.example castle-example - war - 1.5.0 - Castle Java JDK Example + jar + 2.0.0 + Castle Java Example https://github.com/castle/castle-java-example + + + 17 + UTF-8 + + - org.slf4j - slf4j-api - 1.7.12 + org.springframework.boot + spring-boot-starter-web - ch.qos.logback - logback-classic - 1.2.13 + org.springframework.boot + spring-boot-starter-thymeleaf + - javax.servlet - javax.servlet-api - 3.0.1 - provided + nz.net.ultraq.thymeleaf + thymeleaf-layout-dialect + + io.castle castle-java - 2.2.0 + 3.0.0 + + + + org.springframework.boot + spring-boot-starter-test + test @@ -35,9 +55,8 @@ castle-example - org.eclipse.jetty - jetty-maven-plugin - 9.4.6.v20170531 + org.springframework.boot + spring-boot-maven-plugin diff --git a/src/main/java/io/castle/example/CastleExampleApplication.java b/src/main/java/io/castle/example/CastleExampleApplication.java new file mode 100644 index 0000000..52c5d11 --- /dev/null +++ b/src/main/java/io/castle/example/CastleExampleApplication.java @@ -0,0 +1,12 @@ +package io.castle.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CastleExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(CastleExampleApplication.class, args); + } +} diff --git a/src/main/java/io/castle/example/ChallengeServlet.java b/src/main/java/io/castle/example/ChallengeServlet.java deleted file mode 100644 index 87eb5e5..0000000 --- a/src/main/java/io/castle/example/ChallengeServlet.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.example.model.TestUser; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.*; -import java.io.IOException; - - -@WebServlet("/challenge") -public class ChallengeServlet extends HttpServlet { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - CastleApi castleApi = Castle.instance().onRequest(req); - HttpSession session = req.getSession(true); - if (session.isNew()) { - resp.sendRedirect("authentication_error.jsp"); - } else { - Boolean isChallengeSucceeded = Boolean.valueOf(req.getParameter("is_challenge_succeeded")); - Object challengedUserObject = session.getAttribute("challengedUser"); - TestUser challengedUser = (TestUser) challengedUserObject; - String userId = challengedUser.getId().toString(); - if (isChallengeSucceeded) { - castleApi.track("$challenge.succeeded", userId); - session.setAttribute("currentSessionUser", challengedUserObject); - session.removeAttribute("challengedUser"); - resp.sendRedirect("/"); - } else { - castleApi.track("$challenge.failed", userId); - session.invalidate(); - resp.sendRedirect("authentication_error.jsp"); - } - } - - - } -} diff --git a/src/main/java/io/castle/example/EmailChangeRequestServlet.java b/src/main/java/io/castle/example/EmailChangeRequestServlet.java deleted file mode 100644 index 222e5b1..0000000 --- a/src/main/java/io/castle/example/EmailChangeRequestServlet.java +++ /dev/null @@ -1,46 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.example.model.TestUser; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; -import java.io.IOException; - - -@WebServlet("/email_change_request") -public class EmailChangeRequestServlet extends HttpServlet { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - - HttpSession session = req.getSession(); - Object currentSessionUserObject = session.getAttribute("currentSessionUser"); - if (!session.isNew() && currentSessionUserObject != null) { - CastleApi castleApi = Castle.instance().onRequest(req); - TestUser currentSessionUser = (TestUser) currentSessionUserObject; - String userId = currentSessionUser.getId().toString(); - castleApi.track("$email_change.requested", userId); - String password = req.getParameter("password"); - if (password != null - && password.equals(currentSessionUser.getPassword())) { - - session.setAttribute("requestedEmail", req.getParameter("username")); - resp.sendRedirect("email_change_request_succeeded.jsp"); - } else { - castleApi.track("$email_change.failed", userId); - session.invalidate(); - resp.sendRedirect("/authentication_error.jsp"); - } - } else { - session.invalidate(); - resp.sendRedirect("/"); - } - - } -} diff --git a/src/main/java/io/castle/example/EmailChangeServlet.java b/src/main/java/io/castle/example/EmailChangeServlet.java deleted file mode 100644 index 870ffea..0000000 --- a/src/main/java/io/castle/example/EmailChangeServlet.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.example.model.TestUser; -import io.castle.example.model.UserAuthenticationBackend; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; -import java.io.IOException; - - -@WebServlet("/email_change") -public class EmailChangeServlet extends HttpServlet { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - - HttpSession session = req.getSession(); - Object currentSessionUserObject = session.getAttribute("currentSessionUser"); - - if (!session.isNew() && currentSessionUserObject != null) { - CastleApi castleApi = Castle.instance().onRequest(req); - TestUser currentSessionUser = (TestUser) currentSessionUserObject; - Boolean shouldEmailChange = Boolean.valueOf(req.getParameter("should_email_change")); - Object requestedEmailObject = session.getAttribute("requestedEmail"); - String requestedEmail = (String) requestedEmailObject; - String userId = currentSessionUser.getId().toString(); - if (shouldEmailChange && requestedEmail != null) { - try { - UserAuthenticationBackend.updateUserLogin(currentSessionUser.getLogin(), requestedEmail); - } catch (Exception e) { - e.printStackTrace(); - } - castleApi.track("$email_change.succeeded", userId); - session.invalidate(); - resp.sendRedirect("/email_update_success.jsp"); - } else { - castleApi.track("$email_change.failed", userId); - session.invalidate(); - resp.sendRedirect("/authentication_error.jsp"); - } - } else { - session.invalidate(); - resp.sendRedirect("/"); - } - } -} diff --git a/src/main/java/io/castle/example/EventsDemoServlet.java b/src/main/java/io/castle/example/EventsDemoServlet.java deleted file mode 100644 index 4755a79..0000000 --- a/src/main/java/io/castle/example/EventsDemoServlet.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.castle.example; - -import com.google.common.collect.ImmutableMap; -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.client.model.CastleResponse; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.io.PrintWriter; - -/** - * Demonstrates the Events API (schema and query). - */ -@WebServlet("/events-demo") -public class EventsDemoServlet extends HttpServlet { - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - resp.setContentType("text/html; charset=utf-8"); - PrintWriter out = resp.getWriter(); - out.println("Events API demo"); - out.println("

Events API demo

← Home

"); - - CastleApi client = Castle.instance().client(); - - try { - CastleResponse schema = client.eventsSchema(); - ListsDemoServlet.render(out, "eventsSchema", schema); - - CastleResponse query = client.queryEvents(ImmutableMap.builder() - .put("filters", ImmutableMap.of()) - .build()); - ListsDemoServlet.render(out, "queryEvents", query); - } catch (Exception e) { - out.println("
Error: " + e.getMessage() + "
"); - } - - out.println(""); - } -} diff --git a/src/main/java/io/castle/example/ListsDemoServlet.java b/src/main/java/io/castle/example/ListsDemoServlet.java deleted file mode 100644 index d5b7c73..0000000 --- a/src/main/java/io/castle/example/ListsDemoServlet.java +++ /dev/null @@ -1,70 +0,0 @@ -package io.castle.example; - -import com.google.common.collect.ImmutableMap; -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.client.model.CastleResponse; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.io.PrintWriter; - -/** - * Demonstrates the Lists and List items API end to end: - * create a list, add an item, query the items, list all lists and delete the list. - */ -@WebServlet("/lists-demo") -public class ListsDemoServlet extends HttpServlet { - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - resp.setContentType("text/html; charset=utf-8"); - PrintWriter out = resp.getWriter(); - out.println("Lists API demo"); - out.println("

Lists API demo

← Home

"); - - CastleApi client = Castle.instance().client(); - - try { - CastleResponse created = client.createList(ImmutableMap.builder() - .put("name", "Example trusted IPs") - .put("description", "Created by the castle-java example app") - .put("color", "$green") - .put("primary_field", "context.ip") - .build()); - render(out, "createList", created); - - String listId = created.json().getAsJsonObject().get("id").getAsString(); - - CastleResponse item = client.createListItem(listId, ImmutableMap.builder() - .put("primary_value", "1.2.3.4") - .put("comment", "added from the example app") - .build()); - render(out, "createListItem", item); - - CastleResponse items = client.queryListItems(listId, ImmutableMap.builder() - .put("filters", ImmutableMap.of()) - .build()); - render(out, "queryListItems", items); - - CastleResponse all = client.getAllLists(); - render(out, "getAllLists", all); - - CastleResponse deleted = client.deleteList(listId); - render(out, "deleteList", deleted); - } catch (Exception e) { - out.println("
Error: " + e.getMessage() + "
"); - } - - out.println(""); - } - - static void render(PrintWriter out, String label, CastleResponse response) { - out.println("

" + label + " (HTTP " + (response.isSuccessful() ? "2xx" : "error") + ")

"); - out.println("
" + response.json() + "
"); - } -} diff --git a/src/main/java/io/castle/example/LoginServlet.java b/src/main/java/io/castle/example/LoginServlet.java deleted file mode 100644 index 631e889..0000000 --- a/src/main/java/io/castle/example/LoginServlet.java +++ /dev/null @@ -1,102 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.client.model.AuthenticateAction; -import io.castle.client.model.Verdict; -import io.castle.example.model.TestUser; -import io.castle.example.model.UserAuthenticationBackend; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; - -import com.google.common.collect.ImmutableMap; - -import java.io.IOException; -import java.util.logging.Logger; - -@WebServlet("/login") -public class LoginServlet extends HttpServlet { - - private static final Logger LOGGER = Logger.getLogger(LoginServlet.class.getName()); - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - CastleApi castleApi = Castle.instance().onRequest(req); - - String username = req.getParameter("username"); - String password = req.getParameter("password"); - - HttpSession session = req.getSession(true); - - TestUser user = UserAuthenticationBackend.findUser(username); - - if (user != null && user.getPassword().compareTo(password) == 0) { - String id = user.getId().toString(); - ImmutableMap properties = ImmutableMap.builder() - .put("premium", true) - .put("balance", 500) - .build(); - - ImmutableMap traits = ImmutableMap.builder() - .put("email", user.getLogin()) - .put("name", user.getLastname()) - .build(); - - Verdict verdict = castleApi.authenticate( - "$login.succeeded", - id, - properties, - traits - ); - - switch (verdict.getAction()) { - case DENY: { - session.invalidate(); - resp.sendRedirect("authentication_error.jsp"); - } - break; - case ALLOW: { - session.setAttribute("currentSessionUser", user); - resp.sendRedirect("/"); - } - break; - case CHALLENGE: { - castleApi.track("$challenge.requested", id); - session.setAttribute("challengedUser", user); - resp.sendRedirect("challenge.jsp"); - } - break; - } - } else { - if (user != null) { - ImmutableMap properties = ImmutableMap.builder() - .put("email", user.getLogin()) - .build(); - - castleApi.track( - "$login.failed", - user.getId().toString(), - null, - properties - ); - } else { - ImmutableMap properties = ImmutableMap.builder() - .put("email", username) - .build(); - castleApi.track( - "$login.failed", - null, - null, - properties - ); - } - session.invalidate(); - resp.sendRedirect("authentication_error.jsp"); - } - } -} \ No newline at end of file diff --git a/src/main/java/io/castle/example/LogoutServlet.java b/src/main/java/io/castle/example/LogoutServlet.java deleted file mode 100644 index 20a9d58..0000000 --- a/src/main/java/io/castle/example/LogoutServlet.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.example.model.TestUser; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; - -import com.google.common.collect.ImmutableMap; - -import java.io.IOException; - -@WebServlet("/logout") -public class LogoutServlet extends HttpServlet { - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - CastleApi castleApi = Castle.instance().onRequest(req); - HttpSession session = req.getSession(true); - Object userObject = session.getAttribute("currentSessionUser"); - TestUser user = (TestUser) userObject; - - - castleApi.track("$logout.succeeded", user.getId().toString()); - session.invalidate(); - resp.sendRedirect("logout_success.jsp"); - } -} \ No newline at end of file diff --git a/src/main/java/io/castle/example/PasswordChangeServlet.java b/src/main/java/io/castle/example/PasswordChangeServlet.java deleted file mode 100644 index 2f4fcba..0000000 --- a/src/main/java/io/castle/example/PasswordChangeServlet.java +++ /dev/null @@ -1,54 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.example.model.TestUser; -import io.castle.example.model.UserAuthenticationBackend; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; -import java.io.IOException; - - -@WebServlet("/password_change") -public class PasswordChangeServlet extends HttpServlet { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - HttpSession session = req.getSession(); - Object currentSessionUserObject = session.getAttribute("currentSessionUser"); - if (!session.isNew() && currentSessionUserObject != null) { - CastleApi castleApi = Castle.instance().onRequest(req); - TestUser currentSessionUser = (TestUser) currentSessionUserObject; - String currentPassword = req.getParameter("password"); - String newPassword = req.getParameter("new_password"); - String newPasswordConfirm = req.getParameter("new_password_confirm"); - String userId = currentSessionUser.getId().toString(); - if (currentPassword != null - && currentPassword.equals(currentSessionUser.getPassword()) - && newPassword != null - && newPasswordConfirm != null - && newPassword.equals(newPasswordConfirm)) { - try { - UserAuthenticationBackend.updateUserPassword(currentSessionUser.getLogin(), newPassword); - } catch (Exception e) { - e.printStackTrace(); - } - castleApi.track("$password_change.succeeded", userId); - session.invalidate(); - resp.sendRedirect("password_reset_success.jsp"); - } else { - castleApi.track("$password_change.failed", userId); - session.invalidate(); - resp.sendRedirect("/authentication_error.jsp"); - } - } else { - session.invalidate(); - resp.sendRedirect("/"); - } - } -} diff --git a/src/main/java/io/castle/example/PasswordResetRequiredServlet.java b/src/main/java/io/castle/example/PasswordResetRequiredServlet.java deleted file mode 100644 index 7c43d21..0000000 --- a/src/main/java/io/castle/example/PasswordResetRequiredServlet.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.example.model.TestUser; -import io.castle.example.model.UserAuthenticationBackend; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; - -import com.google.common.collect.ImmutableMap; - -import java.io.IOException; - - -@WebServlet("/password_reset_required") -public class PasswordResetRequiredServlet extends HttpServlet { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - HttpSession session = req.getSession(); - - CastleApi castleApi = Castle.instance().onRequest(req); - String login = req.getParameter("login"); - TestUser user = UserAuthenticationBackend.findUser(login); - - if (user != null) { - castleApi.track( - "$password_reset_request.succeeded", - user.getId().toString() - ); - session.setAttribute("passwordResetUser", user); - resp.sendRedirect("password_reset_request_succeeded.jsp"); - } else { - ImmutableMap properties = ImmutableMap.builder() - .put("email", login) - .build(); - - castleApi.track( - "$password_reset_request.failed", - null, - null, - properties - ); - session.invalidate(); - resp.sendRedirect("password_reset_request_error.jsp"); - } - } -} diff --git a/src/main/java/io/castle/example/PasswordResetServlet.java b/src/main/java/io/castle/example/PasswordResetServlet.java deleted file mode 100644 index 25bf61d..0000000 --- a/src/main/java/io/castle/example/PasswordResetServlet.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.example.model.TestUser; -import io.castle.example.model.UserAuthenticationBackend; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; -import java.io.IOException; - - -@WebServlet("/password_reset") -public class PasswordResetServlet extends HttpServlet { - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - HttpSession session = req.getSession(); - Object passwordResetUserObject = session.getAttribute("passwordResetUser"); - if (!session.isNew() && passwordResetUserObject != null) { - CastleApi castleApi = Castle.instance().onRequest(req); - TestUser passwordResetUser = (TestUser) passwordResetUserObject; - Boolean shouldPasswordReset = Boolean.valueOf(req.getParameter("password_reset")); - String newPassword = req.getParameter("new_password"); - String newPasswordConfirm = req.getParameter("new_password_confirm"); - String userId = passwordResetUser.getId().toString(); - if (shouldPasswordReset - && newPassword != null - && newPasswordConfirm != null - && newPassword.equals(newPasswordConfirm)) { - try { - UserAuthenticationBackend.updateUserPassword(passwordResetUser.getLogin(), newPassword); - } catch (Exception e) { - e.printStackTrace(); - } - castleApi.track("$password_reset.succeeded", userId); - session.invalidate(); - resp.sendRedirect("password_reset_success.jsp"); - } else { - castleApi.track("$password_reset.failed", userId); - session.invalidate(); - resp.sendRedirect("authentication_error.jsp"); - } - } else { - session.invalidate(); - resp.sendRedirect("/"); - } - } -} diff --git a/src/main/java/io/castle/example/PrivacyDemoServlet.java b/src/main/java/io/castle/example/PrivacyDemoServlet.java deleted file mode 100644 index 3d9cbcc..0000000 --- a/src/main/java/io/castle/example/PrivacyDemoServlet.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.castle.example; - -import com.google.common.collect.ImmutableMap; -import io.castle.client.Castle; -import io.castle.client.api.CastleApi; -import io.castle.client.model.CastleResponse; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.io.PrintWriter; - -/** - * Demonstrates the privacy data-request API. - */ -@WebServlet("/privacy-demo") -public class PrivacyDemoServlet extends HttpServlet { - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - resp.setContentType("text/html; charset=utf-8"); - PrintWriter out = resp.getWriter(); - out.println("Privacy API demo"); - out.println("

Privacy API demo

← Home

"); - - String userId = req.getParameter("userId"); - if (userId == null || userId.isEmpty()) { - userId = "1"; - } - - CastleApi client = Castle.instance().client(); - - try { - CastleResponse response = client.requestUserData(ImmutableMap.builder() - .put("user_id", userId) - .build()); - ListsDemoServlet.render(out, "requestUserData(" + userId + ")", response); - } catch (Exception e) { - out.println("
Error: " + e.getMessage() + "
"); - } - - out.println(""); - } -} diff --git a/src/main/java/io/castle/example/SetupListener.java b/src/main/java/io/castle/example/SetupListener.java deleted file mode 100644 index 58be792..0000000 --- a/src/main/java/io/castle/example/SetupListener.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; -import io.castle.client.model.CastleSdkConfigurationException; - -import javax.servlet.ServletContextEvent; -import javax.servlet.ServletContextListener; - -public class SetupListener implements ServletContextListener { - - /** - * Example of how to verify the SDK configuration during application initialization to avoid errors - * during request handling. - * - * @param sce - */ - public void contextInitialized(ServletContextEvent sce) { - try { - Castle castle = Castle.verifySdkConfigurationAndInitialize(); - Castle.setSingletonInstance(castle); - } catch (CastleSdkConfigurationException e) { - //The sdk configuration is incorrect. We recommend to shutdown the application by throwing a runtime exception - throw new IllegalStateException("The Castle SDK configuration is not correct", e); - } - } - - public void contextDestroyed(ServletContextEvent sce) { - - } -} diff --git a/src/main/java/io/castle/example/WebhookServlet.java b/src/main/java/io/castle/example/WebhookServlet.java deleted file mode 100644 index c8de831..0000000 --- a/src/main/java/io/castle/example/WebhookServlet.java +++ /dev/null @@ -1,70 +0,0 @@ -package io.castle.example; - -import io.castle.client.Castle; - -import javax.servlet.ServletException; -import javax.servlet.ServletInputStream; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.PrintWriter; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -/** - * Receives Castle webhooks and verifies the {@code X-Castle-Signature} header - * against the raw request body before trusting the payload. - */ -@WebServlet("/webhooks") -public class WebhookServlet extends HttpServlet { - - private static final List RECEIVED = new CopyOnWriteArrayList(); - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - byte[] body = readBody(req); - - boolean valid = Castle.instance().verifyWebhookSignature(req, body); - - if (!valid) { - resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); - resp.getWriter().println("invalid signature"); - return; - } - - RECEIVED.add(0, new String(body, "UTF-8")); - resp.setStatus(HttpServletResponse.SC_OK); - resp.getWriter().println("ok"); - } - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - resp.setContentType("text/html; charset=utf-8"); - PrintWriter out = resp.getWriter(); - out.println("Webhooks"); - out.println("

Received webhooks

← Home

"); - out.println("

POST a Castle webhook to /webhooks with a valid " - + "X-Castle-Signature header to see it verified and listed here.

"); - if (RECEIVED.isEmpty()) { - out.println("

No verified webhooks received yet.

"); - } - for (String payload : RECEIVED) { - out.println("
" + payload + "
"); - } - out.println(""); - } - - private byte[] readBody(HttpServletRequest req) throws IOException { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - ServletInputStream in = req.getInputStream(); - byte[] chunk = new byte[4096]; - int read; - while ((read = in.read(chunk)) != -1) { - buffer.write(chunk, 0, read); - } - return buffer.toByteArray(); - } -} diff --git a/src/main/java/io/castle/example/config/CastleConfig.java b/src/main/java/io/castle/example/config/CastleConfig.java new file mode 100644 index 0000000..ae81de8 --- /dev/null +++ b/src/main/java/io/castle/example/config/CastleConfig.java @@ -0,0 +1,40 @@ +package io.castle.example.config; + +import io.castle.client.Castle; +import io.castle.client.model.CastleSdkConfigurationException; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +/** + * Initialises the Castle SDK singleton once, on startup. The API secret comes + * from the dashboard (Settings → API). + */ +@Configuration +public class CastleConfig { + + private final DemoEnv env; + + public CastleConfig(DemoEnv env) { + this.env = env; + } + + @PostConstruct + public void initialize() { + String secret = env.castleApiSecret(); + if (secret == null || secret.isEmpty()) { + throw new IllegalStateException("castle_api_secret is required"); + } + try { + Castle castle = Castle.initialize( + Castle.configurationBuilder() + .apiSecret(secret) + // Request timeout in ms. + .withTimeout(1500) + .build()); + Castle.setSingletonInstance(castle); + } catch (CastleSdkConfigurationException e) { + throw new IllegalStateException("The Castle SDK configuration is not correct", e); + } + } +} diff --git a/src/main/java/io/castle/example/config/Demo.java b/src/main/java/io/castle/example/config/Demo.java new file mode 100644 index 0000000..8e6e8cd --- /dev/null +++ b/src/main/java/io/castle/example/config/Demo.java @@ -0,0 +1,36 @@ +package io.castle.example.config; + +/** + * A single workflow exposed by the app. Each demo maps to a route (/<url>) + * and a template (templates/<url>.html). + */ +public class Demo { + + private final String url; + private final String friendlyName; + private final String blurb; + private final String wsd; + + public Demo(String url, String friendlyName, String blurb, String wsd) { + this.url = url; + this.friendlyName = friendlyName; + this.blurb = blurb; + this.wsd = wsd; + } + + public String getUrl() { + return url; + } + + public String getFriendlyName() { + return friendlyName; + } + + public String getBlurb() { + return blurb; + } + + public String getWsd() { + return wsd; + } +} diff --git a/src/main/java/io/castle/example/config/DemoEnv.java b/src/main/java/io/castle/example/config/DemoEnv.java new file mode 100644 index 0000000..9edf70f --- /dev/null +++ b/src/main/java/io/castle/example/config/DemoEnv.java @@ -0,0 +1,80 @@ +package io.castle.example.config; + +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +/** + * Resolves configuration and the simulated demo fixture. Only castle_pk and + * castle_api_secret need to be provided (via the environment or a local .env + * file); the "valid user" the demo logs in falls back to baked-in defaults. + */ +@Component +public class DemoEnv { + + private final Map values = new HashMap<>(); + + public DemoEnv() { + // Baked-in fixture defaults. + values.put("location", "localhost"); + values.put("valid_username", "clark.kent@dailyplanet.com"); + values.put("valid_name", "Clark Kent"); + values.put("valid_user_id", "00000000"); + values.put("valid_password", "1234"); + values.put("invalid_password", "qwerty"); + values.put("webhook_url", "https://webhook.site"); + + // A local .env file (if present) overrides the defaults. + loadDotEnv(Paths.get(".env")); + + // The real environment takes precedence over everything else. + System.getenv().forEach((k, v) -> { + if (v != null && !v.isEmpty()) { + values.put(k, v); + } + }); + } + + private void loadDotEnv(Path path) { + if (!Files.isReadable(path)) { + return; + } + try { + for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + String key = trimmed.substring(0, eq).trim(); + String value = trimmed.substring(eq + 1).trim(); + if (!value.isEmpty()) { + values.put(key, value); + } + } + } catch (IOException ignored) { + // A missing or unreadable .env file is not fatal. + } + } + + public String get(String key) { + return values.get(key); + } + + public String castlePk() { + return values.get("castle_pk"); + } + + public String castleApiSecret() { + return values.get("castle_api_secret"); + } +} diff --git a/src/main/java/io/castle/example/config/Demos.java b/src/main/java/io/castle/example/config/Demos.java new file mode 100644 index 0000000..a41f60f --- /dev/null +++ b/src/main/java/io/castle/example/config/Demos.java @@ -0,0 +1,43 @@ +package io.castle.example.config; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The ordered list of workflows shown in the navbar and on the home page. + */ +public final class Demos { + + public static final List LIST = List.of( + new Demo("signup", "sign up", + "Filter a registration ($registration) before the account exists.", null), + new Demo("login", "login", + "Filter the attempt, then assess a successful login with Risk.", + "https://www.websequencediagrams.com/files/render?link=Q9WYp8rNThVZhA1inf2FSLfjChYZTdHXyGB9zqvMNpsaAvKvJPARgo5LI5fM5K4D"), + new Demo("account", "account", + "Update your profile, send a custom event, and log out.", null), + new Demo("password_reset", "password reset", + "Record a password-reset event with the non-blocking log endpoint.", null), + new Demo("lists", "lists", + "Create and fetch lists with the Lists API.", null), + new Demo("privacy", "privacy", + "Request or delete a user's data with the Privacy API.", null), + new Demo("webhooks", "webhooks", + "Verify and inspect incoming Castle webhooks.", null)); + + private static final Map BY_URL = new LinkedHashMap<>(); + + static { + for (Demo d : LIST) { + BY_URL.put(d.getUrl(), d); + } + } + + private Demos() { + } + + public static Demo byUrl(String url) { + return BY_URL.get(url); + } +} diff --git a/src/main/java/io/castle/example/config/WebConfig.java b/src/main/java/io/castle/example/config/WebConfig.java new file mode 100644 index 0000000..4f83339 --- /dev/null +++ b/src/main/java/io/castle/example/config/WebConfig.java @@ -0,0 +1,22 @@ +package io.castle.example.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Serves the static assets and the Castle browser SDK. The browser SDK is + * served straight from the npm install (node_modules) instead of being vendored + * into the repo, matching the other Castle example apps. + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/static/**") + .addResourceLocations("file:static/"); + registry.addResourceHandler("/vendor/castle-js/**") + .addResourceLocations("file:node_modules/@castleio/castle-js/dist/"); + } +} diff --git a/src/main/java/io/castle/example/model/TestUser.java b/src/main/java/io/castle/example/model/TestUser.java deleted file mode 100644 index 86ae418..0000000 --- a/src/main/java/io/castle/example/model/TestUser.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.castle.example.model; - -public class TestUser { - - private Integer id; - private String login; - private String password; - private String username; - private String lastname; - - public TestUser() { - } - - public TestUser(Integer id, String login, String password, String username, String lastname) { - this.id = id; - this.login = login; - this.password = password; - this.username = username; - this.lastname = lastname; - } - - public String getLogin() { - return login; - } - - public void setLogin(String login) { - this.login = login; - } - - public String getPassword() { - return password; - } - - public void setPassword(String password) { - this.password = password; - } - - public String getUsername() {return username; } - - public void setUsername(String username) { this.username = username; } - - public Integer getId() { return id; } - - public void setId(Integer id) { this.id = id; } - - public String getLastname() { return lastname; } - - public void setLastname(String lastname) { this.lastname = lastname; } -} diff --git a/src/main/java/io/castle/example/model/UserAuthenticationBackend.java b/src/main/java/io/castle/example/model/UserAuthenticationBackend.java deleted file mode 100644 index 52bf916..0000000 --- a/src/main/java/io/castle/example/model/UserAuthenticationBackend.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.castle.example.model; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -public class UserAuthenticationBackend { - - static AtomicInteger idCounter = new AtomicInteger(); - - static Map testUsersByLogin = new HashMap() { - { - TestUser admin = createNewUser("admin@example.com", "admin", "Jane", "Doe"); - TestUser josh = createNewUser("josh@example.com", "anyPassword", "Josh", "Bond"); - put(admin.getLogin(), admin); - put(josh.getLogin(), josh); - } - }; - - static void addUpdateUser(TestUser user) { - testUsersByLogin.put(user.getLogin(), user); - } - - public static void updateUserLogin(String currentLogin, String newLogin) throws Exception { - TestUser userData = findUser(currentLogin); - if (userData != null) { - userData.setLogin(newLogin); - testUsersByLogin.remove(currentLogin); - addUpdateUser(userData); - } else { - throw new Exception("No such user"); - } - } - - public static void updateUserPassword(String login, String password) throws Exception { - TestUser userData = findUser(login); - if (userData != null) { - userData.setPassword(password); - addUpdateUser(userData); - } else { - throw new Exception("No such user"); - } - } - - public static TestUser findUser(String userLogin) { - return testUsersByLogin.get(userLogin); - } - - public static TestUser createNewUser(String login, String password, String name, String lastname) { - return new TestUser(getFreshInteger(), login, password, name, lastname); - } - - private static Integer getFreshInteger() { - return Integer.valueOf(idCounter.getAndIncrement()); - } - -} diff --git a/src/main/java/io/castle/example/web/CastleSupport.java b/src/main/java/io/castle/example/web/CastleSupport.java new file mode 100644 index 0000000..5f9d0c3 --- /dev/null +++ b/src/main/java/io/castle/example/web/CastleSupport.java @@ -0,0 +1,54 @@ +package io.castle.example.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import io.castle.client.Castle; +import io.castle.client.model.CastleResponse; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Helpers shared by the demo endpoints: building the scoring payload (with the + * request context) and turning a {@link CastleResponse} into a plain value the + * JSON serializer can render. + */ +final class CastleSupport { + + private CastleSupport() { + } + + /** + * Copies the echoed payload and attaches the Castle request context (IP, + * headers, client id) derived from the incoming request. The context is kept + * off the echoed payload so the browser only sees the meaningful fields. + */ + static ImmutableMap withContext(Map payload, HttpServletRequest req) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + for (Map.Entry entry : payload.entrySet()) { + if (entry.getValue() != null) { + builder.put(entry.getKey(), entry.getValue()); + } + } + builder.put("context", Castle.instance().contextBuilder().fromHttpServletRequest(req).build()); + return builder.build(); + } + + /** + * Converts the SDK's Gson response into a structure Jackson can serialize. + */ + static Object toJava(CastleResponse response, ObjectMapper mapper) { + try { + return mapper.readValue(response.json().toString(), Object.class); + } catch (Exception e) { + return response.json().toString(); + } + } + + static Map error(String message) { + Map error = new LinkedHashMap<>(); + error.put("error", message); + return error; + } +} diff --git a/src/main/java/io/castle/example/web/DemoController.java b/src/main/java/io/castle/example/web/DemoController.java new file mode 100644 index 0000000..2f0c6b5 --- /dev/null +++ b/src/main/java/io/castle/example/web/DemoController.java @@ -0,0 +1,393 @@ +package io.castle.example.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import io.castle.client.Castle; +import io.castle.client.api.CastleApi; +import io.castle.client.model.CastleResponse; +import io.castle.client.model.generated.ListColor; +import io.castle.client.model.generated.ListRequest; +import io.castle.client.model.generated.ListResponse; +import io.castle.example.config.DemoEnv; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * The JSON endpoints driven by the browser. Each one builds the payload it + * sends to Castle, echoes it back (without the noisy context object) and + * returns the verdict so the page can render it. + */ +@RestController +public class DemoController { + + // A fixed timestamp reused for the simulated valid user. + private static final String REGISTERED_AT = "2020-02-23T22:28:55.387Z"; + + private final DemoEnv env; + private final ObjectMapper mapper; + private final WebhookStore webhooks; + + public DemoController(DemoEnv env, ObjectMapper mapper, WebhookStore webhooks) { + this.env = env; + this.mapper = mapper; + this.webhooks = webhooks; + } + + // --- Filter (registration) ------------------------------------------------ + + @PostMapping("/evaluate_signup") + public Map evaluateSignup(@RequestBody(required = false) Map body, + HttpServletRequest req) { + Map b = orEmpty(body); + String email = str(b, "email"); + String requestToken = str(b, "request_token"); + + String type = "$registration"; + String status; + + Map payload = new LinkedHashMap<>(); + payload.put("type", type); + payload.put("params", mapOf("email", email)); + payload.put("request_token", requestToken); + if (email.equals(env.get("valid_username"))) { + status = "$failed"; + payload.put("matching_user_id", env.get("valid_user_id")); + } else { + status = "$attempted"; + } + payload.put("status", status); + + Object result = scoring(() -> client().filter(CastleSupport.withContext(payload, req))); + + Map out = new LinkedHashMap<>(); + out.put("api_endpoint", "filter"); + out.put("payload_to_castle", payload); + out.put("result", result); + out.put("castle_type", type); + out.put("castle_status", status); + return out; + } + + // --- Filter -> Risk (login) ---------------------------------------------- + + @PostMapping("/evaluate_login") + public Map evaluateLogin(@RequestBody(required = false) Map body, + HttpServletRequest req) { + Map b = orEmpty(body); + String email = str(b, "email"); + String password = str(b, "password"); + String requestToken = str(b, "request_token"); + + List> steps = new ArrayList<>(); + + // Step 1 — always filter the attempt up front (anonymous -> params). + steps.add(loginStep(req, "filter", "$attempted", requestToken, + Map.of("params", mapOf("email", email)))); + + // Step 2 — the outcome, on the same request token. + if (email.equals(env.get("valid_username")) && password.equals(env.get("valid_password"))) { + Map user = new LinkedHashMap<>(); + user.put("id", env.get("valid_user_id")); + user.put("email", email); + user.put("registered_at", REGISTERED_AT); + steps.add(loginStep(req, "risk", "$succeeded", requestToken, Map.of("user", user))); + } else { + Map fields = new LinkedHashMap<>(); + fields.put("params", mapOf("email", email)); + if (email.equals(env.get("valid_username"))) { + fields.put("matching_user_id", env.get("valid_user_id")); + } + steps.add(loginStep(req, "filter", "$failed", requestToken, fields)); + } + + Map out = new LinkedHashMap<>(); + out.put("steps", steps); + return out; + } + + private Map loginStep(HttpServletRequest req, String apiEndpoint, String status, + String requestToken, Map fields) { + Map payload = new LinkedHashMap<>(); + payload.put("type", "$login"); + payload.put("status", status); + payload.put("request_token", requestToken); + payload.putAll(fields); + + Object result = scoring(() -> apiEndpoint.equals("risk") + ? client().risk(CastleSupport.withContext(payload, req)) + : client().filter(CastleSupport.withContext(payload, req))); + + Map step = new LinkedHashMap<>(); + step.put("api_endpoint", apiEndpoint); + step.put("payload_to_castle", payload); + step.put("result", result); + step.put("castle_type", "$login"); + step.put("castle_status", status); + return step; + } + + // --- Risk (profile update) ----------------------------------------------- + + @PostMapping("/evaluate_profile_update") + public Map evaluateProfileUpdate(@RequestBody(required = false) Map body, + HttpServletRequest req) { + Map b = orEmpty(body); + String name = str(b, "name"); + String email = str(b, "email"); + if (email.isEmpty()) { + email = env.get("valid_username"); + } + String requestToken = str(b, "request_token"); + + String type = "$profile_update"; + String status = "$succeeded"; + + Map user = new LinkedHashMap<>(); + user.put("id", env.get("valid_user_id")); + user.put("email", email); + user.put("name", name); + user.put("registered_at", REGISTERED_AT); + + Map payload = new LinkedHashMap<>(); + payload.put("type", type); + payload.put("status", status); + payload.put("user", user); + payload.put("request_token", requestToken); + + Object result = scoring(() -> client().risk(CastleSupport.withContext(payload, req))); + + Map out = new LinkedHashMap<>(); + out.put("api_endpoint", "risk"); + out.put("payload_to_castle", payload); + out.put("result", result); + out.put("castle_type", type); + out.put("castle_status", status); + return out; + } + + // --- Log (password reset) ------------------------------------------------ + + @PostMapping("/evaluate_new_password") + public Map evaluateNewPassword(@RequestBody(required = false) Map body, + HttpServletRequest req) { + Map b = orEmpty(body); + String password = str(b, "password"); + String requestToken = str(b, "request_token"); + + // A new password that differs from the current one is a successful reset. + String status = password.equals(env.get("valid_password")) ? "$failed" : "$succeeded"; + String type = "$password_reset"; + + Map user = new LinkedHashMap<>(); + user.put("id", env.get("valid_user_id")); + user.put("email", env.get("valid_username")); + user.put("registered_at", REGISTERED_AT); + + Map payload = new LinkedHashMap<>(); + payload.put("type", type); + payload.put("status", status); + payload.put("user", user); + payload.put("request_token", requestToken); + + Object result = logResult(() -> client().log(CastleSupport.withContext(payload, req))); + + Map out = new LinkedHashMap<>(); + out.put("api_endpoint", "log"); + out.put("payload_to_castle", payload); + out.put("result", result); + out.put("type", type); + out.put("status", status); + return out; + } + + // --- Log (logout) -------------------------------------------------------- + + @PostMapping("/evaluate_logout") + public Map evaluateLogout(@RequestBody(required = false) Map body, + HttpServletRequest req) { + Map b = orEmpty(body); + String requestToken = str(b, "request_token"); + + String type = "$logout"; + String status = "$succeeded"; + + Map user = new LinkedHashMap<>(); + user.put("id", env.get("valid_user_id")); + user.put("email", env.get("valid_username")); + + Map payload = new LinkedHashMap<>(); + payload.put("type", type); + payload.put("status", status); + payload.put("user", user); + payload.put("request_token", requestToken); + + Object result = logResult(() -> client().log(CastleSupport.withContext(payload, req))); + + Map out = new LinkedHashMap<>(); + out.put("api_endpoint", "log"); + out.put("payload_to_castle", payload); + out.put("result", result); + out.put("castle_type", type); + out.put("castle_status", status); + return out; + } + + // --- Lists API ----------------------------------------------------------- + + @PostMapping("/create_list") + public Map createList(@RequestBody(required = false) Map body) { + Map b = orEmpty(body); + + Map payload = new LinkedHashMap<>(); + payload.put("name", orDefault(str(b, "name"), "demo-blocklist")); + payload.put("color", orDefault(str(b, "color"), "$red")); + payload.put("primary_field", orDefault(str(b, "primary_field"), "user.email")); + + Object result; + try { + CastleApi client = client(); + ListRequest request = new ListRequest(); + request.setName((String) payload.get("name")); + request.setColor(ListColor.fromValue((String) payload.get("color"))); + request.setPrimaryField((String) payload.get("primary_field")); + ListResponse created = client.createList(request); + List all = client.listAllLists(); + Map ok = new LinkedHashMap<>(); + ok.put("created", mapper.convertValue(created, Object.class)); + ok.put("all_lists", mapper.convertValue(all, Object.class)); + result = ok; + } catch (Exception e) { + result = CastleSupport.error(e.getMessage()); + } + + Map out = new LinkedHashMap<>(); + out.put("api_endpoint", "lists"); + out.put("payload_to_castle", payload); + out.put("result", result); + return out; + } + + // --- Privacy API --------------------------------------------------------- + + @PostMapping("/privacy_user_data") + public Map privacyUserData(@RequestBody(required = false) Map body) { + Map b = orEmpty(body); + String action = orDefault(str(b, "action"), "request"); + + Map payload = new LinkedHashMap<>(); + payload.put("identifier", orDefault(str(b, "identifier"), env.get("valid_username"))); + payload.put("identifier_type", orDefault(str(b, "identifier_type"), "$email")); + + String apiEndpoint; + Object result; + try { + CastleApi client = client(); + CastleResponse response; + if ("delete".equals(action)) { + apiEndpoint = "privacy (delete)"; + response = client.delete("/v1/privacy/users", toImmutable(payload)); + } else { + apiEndpoint = "privacy (request)"; + response = client.requestUserData(toImmutable(payload)); + } + result = CastleSupport.toJava(response, mapper); + } catch (Exception e) { + apiEndpoint = "privacy"; + result = CastleSupport.error(e.getMessage()); + } + + Map out = new LinkedHashMap<>(); + out.put("api_endpoint", apiEndpoint); + out.put("payload_to_castle", payload); + out.put("result", result); + return out; + } + + // --- Webhook receiver ---------------------------------------------------- + + @PostMapping(value = "/webhooks/castle") + public ResponseEntity receiveWebhook(@RequestBody(required = false) byte[] rawBody, + HttpServletRequest req) { + byte[] body = rawBody != null ? rawBody : new byte[0]; + + if (!Castle.instance().verifyWebhookSignature(req, body)) { + return ResponseEntity.status(404).contentType(MediaType.TEXT_PLAIN) + .body("invalid signature"); + } + + webhooks.add(prettyPrint(body)); + return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body("ok"); + } + + // --- helpers ------------------------------------------------------------- + + private CastleApi client() { + return Castle.instance().client(); + } + + private Object scoring(Supplier call) { + try { + return CastleSupport.toJava(call.get(), mapper); + } catch (Exception e) { + return CastleSupport.error(e.getMessage()); + } + } + + private Object logResult(Supplier call) { + try { + call.get(); + return Map.of("logged", true); + } catch (Exception e) { + return CastleSupport.error(e.getMessage()); + } + } + + private String prettyPrint(byte[] body) { + String raw = new String(body, java.nio.charset.StandardCharsets.UTF_8); + try { + Object parsed = mapper.readValue(raw, Object.class); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(parsed); + } catch (Exception e) { + return raw; + } + } + + private static ImmutableMap toImmutable(Map payload) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + payload.forEach((k, v) -> { + if (v != null) { + builder.put(k, v); + } + }); + return builder.build(); + } + + private static Map mapOf(String key, Object value) { + Map map = new LinkedHashMap<>(); + map.put(key, value); + return map; + } + + private static Map orEmpty(Map body) { + return body != null ? body : new LinkedHashMap<>(); + } + + private static String str(Map body, String key) { + Object value = body.get(key); + return value == null ? "" : value.toString(); + } + + private static String orDefault(String value, String fallback) { + return value == null || value.isEmpty() ? fallback : value; + } +} diff --git a/src/main/java/io/castle/example/web/PageController.java b/src/main/java/io/castle/example/web/PageController.java new file mode 100644 index 0000000..720aff0 --- /dev/null +++ b/src/main/java/io/castle/example/web/PageController.java @@ -0,0 +1,82 @@ +package io.castle.example.web; + +import io.castle.example.config.Demo; +import io.castle.example.config.DemoEnv; +import io.castle.example.config.Demos; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Renders the server-side pages: the home grid, the per-workflow pages and the + * webhooks page. + */ +@Controller +public class PageController { + + private final DemoEnv env; + private final WebhookStore webhooks; + + public PageController(DemoEnv env, WebhookStore webhooks) { + this.env = env; + this.webhooks = webhooks; + } + + // Default params rendered with every page. + private void defaultParams(Model model) { + model.addAttribute("castle_pk", env.castlePk()); + model.addAttribute("location", env.get("location")); + model.addAttribute("demo_list", Demos.LIST); + model.addAttribute("username", env.get("valid_username")); + model.addAttribute("valid_username", env.get("valid_username")); + model.addAttribute("valid_password", env.get("valid_password")); + model.addAttribute("invalid_password", env.get("invalid_password")); + model.addAttribute("valid_name", env.get("valid_name")); + model.addAttribute("valid_user_id", env.get("valid_user_id")); + model.addAttribute("webhook_url", env.get("webhook_url")); + } + + @GetMapping("/") + public String home(Model model) { + defaultParams(model); + model.addAttribute("home", true); + return "demo"; + } + + @GetMapping("/webhooks") + public String webhooks(HttpServletRequest req, Model model) { + Demo d = Demos.byUrl("webhooks"); + defaultParams(model); + model.addAttribute("demo_name", "webhooks"); + model.addAttribute("friendly_name", d.getFriendlyName()); + model.addAttribute("blurb", d.getBlurb()); + + String proto = req.getHeader("X-Forwarded-Proto"); + if (proto == null || proto.isEmpty()) { + proto = req.getScheme(); + } + model.addAttribute("webhook_endpoint", proto + "://" + req.getHeader("host") + "/webhooks/castle"); + model.addAttribute("webhooks_received", webhooks.list()); + return "webhooks"; + } + + @GetMapping("/{demoName}") + public String demo(@PathVariable String demoName, Model model, HttpServletResponse resp) { + defaultParams(model); + Demo d = Demos.byUrl(demoName); + if (d == null) { + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + model.addAttribute("message", "Page not found"); + return "error"; + } + model.addAttribute("demo_name", demoName); + model.addAttribute("friendly_name", d.getFriendlyName()); + model.addAttribute("blurb", d.getBlurb()); + model.addAttribute("wsd", d.getWsd()); + return demoName; + } +} diff --git a/src/main/java/io/castle/example/web/WebhookStore.java b/src/main/java/io/castle/example/web/WebhookStore.java new file mode 100644 index 0000000..f543e16 --- /dev/null +++ b/src/main/java/io/castle/example/web/WebhookStore.java @@ -0,0 +1,56 @@ +package io.castle.example.web; + +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * In-memory store of the most recent webhooks received from Castle. A real app + * would persist these; a list is plenty for a localhost demo. + */ +@Component +public class WebhookStore { + + public static final int MAX = 50; + + private final AtomicInteger seq = new AtomicInteger(); + private final List entries = new ArrayList<>(); + + public synchronized void add(String prettyBody) { + entries.add(0, new Entry(seq.incrementAndGet(), Instant.now().toString(), prettyBody)); + while (entries.size() > MAX) { + entries.remove(entries.size() - 1); + } + } + + public synchronized List 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:

+
    +
  1. profile update$profile_update sent to /risk.
  2. +
  3. custom eventCastle.custom() in the browser.
  4. +
  5. logout$logout via the non-blocking /log endpoint.
  6. +
+
+ + + + + + + + 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.

+
+ + +
+ +
+ +
+
+
workflow
+

workflow

+ +
+
+
+ +
+
+
+ + +
+ View the web sequence diagram → +
+
+ + +
+ + + +
+ +
+ + + + + + 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/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:

+
    +
  1. the attempt is always filtered first → $login / $attempted sent to /filter (anonymous, so the email goes in params).
  2. +
  3. valid username + valid password$login / $succeeded sent to /risk; act on the verdict (allow, challenge, deny).
  4. +
  5. wrong password / unknown user$login / $failed sent to /filter.
  6. +
+
+ + + + + + + + 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:

+
    +
  1. a new email$registration / $attempted; act on the verdict (allow, challenge, deny) before creating the account.
  2. +
  3. an email that already exists$registration / $failed (resolved to the existing user via matching_user_id).
  4. +
+
+ + + + + + + + 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.

+ +
+ + +
+ +
+ Refresh +
+ + +
+
#1 · time
+
{}
+
+
+

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

-
-
Select a new email address to associate with your account.
-
-
- - " - spellcheck="false" id="j_username" name="username"> -
-
-
Please type your password in order to confirm.
-
-
- - -
-
-
- -
-
-
-
- - -
-
- - -
- - <% 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 fb91522..0000000 --- a/src/main/webapp/index.jsp +++ /dev/null @@ -1,105 +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/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: [], +}; From 550622a21f68b881a7224eb6bbc4b3b81360458b Mon Sep 17 00:00:00 2001 From: Bartosz Date: Thu, 11 Jun 2026 23:18:33 +0200 Subject: [PATCH 3/4] Add tests for the example app Add a SpringBootTest/MockMvc suite covering context startup, the home page, and webhook signature verification (accepts a valid X-Castle-Signature, rejects an invalid one). A test castle_api_secret is injected through the surefire environment so the Castle singleton initialises during tests. --- pom.xml | 10 +++ .../CastleExampleApplicationTests.java | 63 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/test/java/io/castle/example/CastleExampleApplicationTests.java diff --git a/pom.xml b/pom.xml index e72647f..644f202 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,16 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + + test_secret + test_pk + + +
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)); + } +} From 26343bf4ae78a9fcae44b7b1f97dd869765cf7b8 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Fri, 12 Jun 2026 10:31:39 +0200 Subject: [PATCH 4/4] Add an Events API demo Add an /events workflow that calls eventsSchema and queryEvents and renders both responses, registered in the demo catalog and reachable from the home page alongside the lists, privacy and webhooks demos. --- .../java/io/castle/example/config/Demos.java | 2 + .../io/castle/example/web/DemoController.java | 37 +++++++++++++++++++ src/main/resources/templates/events.html | 37 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/main/resources/templates/events.html diff --git a/src/main/java/io/castle/example/config/Demos.java b/src/main/java/io/castle/example/config/Demos.java index a41f60f..189c492 100644 --- a/src/main/java/io/castle/example/config/Demos.java +++ b/src/main/java/io/castle/example/config/Demos.java @@ -23,6 +23,8 @@ public final class Demos { "Create and fetch lists with the Lists API.", null), new Demo("privacy", "privacy", "Request or delete a user's data with the Privacy API.", null), + new Demo("events", "events", + "Fetch the event schema and query events with the Events API.", null), new Demo("webhooks", "webhooks", "Verify and inspect incoming Castle webhooks.", null)); diff --git a/src/main/java/io/castle/example/web/DemoController.java b/src/main/java/io/castle/example/web/DemoController.java index 2f0c6b5..9953335 100644 --- a/src/main/java/io/castle/example/web/DemoController.java +++ b/src/main/java/io/castle/example/web/DemoController.java @@ -313,6 +313,43 @@ public Map privacyUserData(@RequestBody(required = false) Map eventsDemo(@RequestBody(required = false) Map body) { + Map b = orEmpty(body); + String type = orDefault(str(b, "type"), "$login"); + + List> steps = new ArrayList<>(); + CastleApi client = client(); + + Map schemaStep = new LinkedHashMap<>(); + schemaStep.put("api_endpoint", "events/schema"); + try { + schemaStep.put("result", CastleSupport.toJava(client.eventsSchema(), mapper)); + } catch (Exception e) { + schemaStep.put("result", CastleSupport.error(e.getMessage())); + } + steps.add(schemaStep); + + Map queryPayload = new LinkedHashMap<>(); + queryPayload.put("type", type); + + Map queryStep = new LinkedHashMap<>(); + queryStep.put("api_endpoint", "events/query"); + queryStep.put("payload_to_castle", queryPayload); + try { + queryStep.put("result", CastleSupport.toJava(client.queryEvents(toImmutable(queryPayload)), mapper)); + } catch (Exception e) { + queryStep.put("result", CastleSupport.error(e.getMessage())); + } + steps.add(queryStep); + + Map out = new LinkedHashMap<>(); + out.put("steps", steps); + return out; + } + // --- Webhook receiver ---------------------------------------------------- @PostMapping(value = "/webhooks/castle") 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.

+
+ + + + + + + +