Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env_example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ location=localhost
invalid_password=qwerty
valid_password={{valid_password}}
valid_username=clark.kent@dailyplanet.com
valid_name=Clark Kent
valid_user_id=00000000
webhook_url=https://webhook.site
PORT=4006
15 changes: 15 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,18 @@ jobs:
cache: npm
- run: npm ci
- run: npm test

react:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: latest
cache: npm
cache-dependency-path: react/package-lock.json
- run: npm ci
working-directory: react
- run: npm run build
working-directory: react
193 changes: 156 additions & 37 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require('dotenv').config({ quiet: true });

const fs = require('fs');
const path = require('path');
const express = require('express');

Expand Down Expand Up @@ -49,6 +50,13 @@ function buildApp(castle = require('./castle')) {
);
app.use('/vendor/castle-js', express.static(CASTLE_JS_DIR));

// The post-login /account page is a React app (see ./react). When built, its
// bundle lives in react/dist and is served from /react-app.
const REACT_DIST = path.join(__dirname, 'react', 'dist');
const REACT_ENTRY = '/react-app/assets/account.js';
const REACT_STYLES = '/react-app/assets/account.css';
app.use('/react-app', express.static(REACT_DIST));

// Build the request context (IP, headers, client id) Castle needs from a Node
// request. Lists/Privacy/Events are account-level and don't need it.
const buildContext = (req) =>
Expand All @@ -65,6 +73,23 @@ function buildApp(castle = require('./castle')) {
res.render('demo', { ...getDefaultParams(), home: true });
});

// Post-login account page. Serves a Pug shell that mounts the React app and
// hands it the publishable key + current user via window.CASTLE_ACCOUNT.
app.get('/account', (_req, res) => {
res.render('account', {
...getDefaultParams(),
account: true,
react_built: fs.existsSync(path.join(REACT_DIST, 'assets', 'account.js')),
react_js: REACT_ENTRY,
react_css: REACT_STYLES,
account_user: {
id: process.env.valid_user_id || null,
email: process.env.valid_username || null,
name: process.env.valid_name || 'Clark Kent',
},
});
});

app.get('/:demoName', (req, res) => {
const params = getDefaultParams();
const { demoName } = req.params;
Expand All @@ -81,6 +106,49 @@ function buildApp(castle = require('./castle')) {
return res.render(demoName, params);
});

// -------------------------------------------------------------------------
// Risk / Filter (registration)
// -------------------------------------------------------------------------

app.post('/evaluate_signup', async (req, res) => {
const { name, email, request_token } = req.body;

const castleType = '$registration';
// An email that's already taken (the known demo user) is a failed
// registration and goes to /filter; a fresh sign-up is risk-assessed.
const alreadyRegistered = email === process.env.valid_username;
const castleStatus = alreadyRegistered ? '$failed' : '$succeeded';
const apiEndpoint = alreadyRegistered ? 'filter' : 'risk';

const payloadToCastle = {
type: castleType,
status: castleStatus,
user: { id: process.env.valid_user_id, email, name },
request_token,
context: buildContext(req),
};

let result;
try {
result =
apiEndpoint === 'risk'
? await castle.risk(payloadToCastle)
: await castle.filter(payloadToCastle);
} catch (err) {
result = errorResult(err);
}

const { context, ...echoedPayload } = payloadToCastle;

res.json({
api_endpoint: apiEndpoint,
payload_to_castle: echoedPayload,
result,
castle_type: castleType,
castle_status: castleStatus,
});
});

// -------------------------------------------------------------------------
// Risk / Filter (login)
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -145,6 +213,49 @@ function buildApp(castle = require('./castle')) {
});
});

// -------------------------------------------------------------------------
// Risk (profile update) — driven by the React /account page
// -------------------------------------------------------------------------

app.post('/evaluate_profile_update', async (req, res) => {
const { name, email, request_token } = req.body;

const castleType = '$profile_update';
const castleStatus = '$succeeded';

const payloadToCastle = {
type: castleType,
status: castleStatus,
user: {
id: process.env.valid_user_id,
email: email || process.env.valid_username,
name,
registered_at: registeredAt,
},
request_token,
context: buildContext(req),
};

// A profile change is a sensitive action, so evaluate it with /risk and act
// on the verdict (allow / challenge / deny).
let result;
try {
result = await castle.risk(payloadToCastle);
} catch (err) {
result = errorResult(err);
}

const { context, ...echoedPayload } = payloadToCastle;

res.json({
api_endpoint: 'risk',
payload_to_castle: echoedPayload,
result,
castle_type: castleType,
castle_status: castleStatus,
});
});

// -------------------------------------------------------------------------
// Log (password reset)
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -189,6 +300,40 @@ function buildApp(castle = require('./castle')) {
});
});

// Logout is recorded with the non-blocking log endpoint as well.
app.post('/evaluate_logout', async (req, res) => {
const { request_token } = req.body;

const castleType = '$logout';
const payloadToCastle = {
type: castleType,
status: '$succeeded',
user: {
id: process.env.valid_user_id,
email: process.env.valid_username,
},
request_token,
context: buildContext(req),
};

let error;
try {
await castle.log(payloadToCastle);
} catch (err) {
error = errorResult(err).error;
}

const { context, ...echoedPayload } = payloadToCastle;

res.json({
api_endpoint: 'log',
payload_to_castle: echoedPayload,
result: error ? { error } : { logged: true },
castle_type: castleType,
castle_status: '$succeeded',
});
});

// -------------------------------------------------------------------------
// Lists API
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -242,49 +387,23 @@ function buildApp(castle = require('./castle')) {
res.json({ api_endpoint: apiEndpoint, payload_to_castle: payload, result });
});

// -------------------------------------------------------------------------
// Events API
// -------------------------------------------------------------------------

app.post('/events_schema', async (_req, res) => {
let result;
try {
result = await castle.eventsSchema();
} catch (err) {
result = errorResult(err);
}

res.json({ api_endpoint: 'events/schema', payload_to_castle: {}, result });
});

app.post('/query_events', async (req, res) => {
const payload = {
filters: [
{
field: req.body.field || 'name',
op: req.body.op || '$eq',
value: req.body.value || '$login',
},
],
sort: { field: 'created_at', order: 'desc' },
};

let result;
try {
result = await castle.queryEvents(payload);
} catch (err) {
result = errorResult(err);
}

res.json({ api_endpoint: 'events/query', payload_to_castle: payload, result });
});

return app;
}

// Start the server only when run directly (`node app.js`), not when imported
// by the test suite.
if (require.main === module) {
// Castle.log is fire-and-forget: it dispatches the request without surfacing
// the promise, so a failed background log (e.g. bad credentials) would
// otherwise become an unhandled rejection and crash the process. Log it and
// keep serving instead.
process.on('unhandledRejection', (err) => {
console.error(
'Unhandled rejection (ignored):',
err && err.message ? err.message : err
);
});

const port = process.env.PORT || 4006;
buildApp().listen(port, () => {
console.log(`Castle Node demo listening on http://localhost:${port}`);
Expand Down
8 changes: 4 additions & 4 deletions demo_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

// Each demo maps to a route (/<key>) and a Pug template (views/<key>.pug).
const demos = {
signup: {
friendly_name: 'sign up',
blurb: 'Evaluate a registration ($registration) with the risk endpoint.',
},
login: {
friendly_name: 'login',
blurb: 'Evaluate a login with the risk and filter endpoints.',
Expand All @@ -19,10 +23,6 @@ const demos = {
friendly_name: 'privacy',
blurb: "Request or delete a user's data with the Privacy API.",
},
events: {
friendly_name: 'events',
blurb: 'Inspect your event schema and query events.',
},
};

const demoList = Object.entries(demos).map(([url, demo]) => ({ url, ...demo }));
Expand Down
7 changes: 7 additions & 0 deletions react/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Castle publishable key (pk_...), used by the browser SDK in the React app.
# Copy this file to react/.env and fill in the value from your Castle dashboard.
VITE_CASTLE_PK=

# Optional: where the Express demo backend (this repo's app.js) is running.
# Defaults to http://localhost:4006.
# VITE_BACKEND_URL=http://localhost:4006
7 changes: 7 additions & 0 deletions react/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
.env
.env.*
!.env.example
*.local
*.tsbuildinfo
68 changes: 68 additions & 0 deletions react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Castle React integration

A **React + Vite + TypeScript** app that powers the post-login `/account` page of
the Express demo. It shows how to integrate the Castle browser SDK
([`@castleio/castle-js`](https://www.npmjs.com/package/@castleio/castle-js)) in a
React app: configure it once, mint a fresh request token per action, and drive
the backend's `risk` / `log` endpoints.

It demonstrates the patterns that matter when wiring Castle into React:

- **Configure the SDK once.** `CastleProvider` calls `Castle.configure({ pk })`
a single time (guarded against React StrictMode's double mount) and exposes a
typed API via the `useCastle()` hook.
- **Mint a fresh request token per action.** Each action (profile update,
logout) calls `createRequestToken()` and forwards it to the backend, which
sends it to Castle's `risk` / `log` endpoint.
- **Custom events.** `trackCustom()` wraps `Castle.custom(...)`.
- **Degrade gracefully.** With no publishable key the hook still resolves
(returning an empty token) so the UI keeps working.

The workflows on the account page:

- **profile update** → `$profile_update` to `/risk`
- **custom event** → `Castle.custom()`
- **logout** → `$logout` via the non-blocking `/log` endpoint

## Layout

```
react/
├── src/
│ ├── castle/CastleProvider.tsx # configure() once + useCastle() hook
│ ├── config.ts # reads window.CASTLE_ACCOUNT (pk + user)
│ ├── components/ProfileForm.tsx # createRequestToken() -> profile update
│ ├── components/AccountActions.tsx # custom event + logout
│ ├── components/ResultPanel.tsx # renders the verdict + raw JSON
│ ├── api.ts # typed fetch to the backend endpoints
│ └── App.tsx
├── vite.config.ts # base /react-app/, fixed output filenames
└── tailwind.config.js # shared dark-theme design tokens
```

## How it's served

The app is built and served by Express from `/react-app`, and mounted into the
`/account` Pug shell. The shell injects the publishable key and current user via
`window.CASTLE_ACCOUNT`, so there is a single origin and the API calls are
same-origin.

```bash
# from the repo root
npm install
npm run build --prefix react # type-checks and bundles to react/dist
npm start # http://localhost:4006 → open /account
```

## Standalone development

For rapid React iteration you can run the Vite dev server. With `base` set, open
it at `http://localhost:5173/react-app/`; the API routes are proxied to the
Express backend (override with `VITE_BACKEND_URL`).

```bash
cd react
npm install
cp .env.example .env # optionally set VITE_CASTLE_PK
npm run dev
```
Loading
Loading