diff --git a/.env_example b/.env_example index 8de8574..b30f2ed 100644 --- a/.env_example +++ b/.env_example @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f6811d..b0c4174 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/app.js b/app.js index 3d42d85..0bfa72d 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ require('dotenv').config({ quiet: true }); +const fs = require('fs'); const path = require('path'); const express = require('express'); @@ -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) => @@ -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; @@ -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) // ------------------------------------------------------------------------- @@ -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) // ------------------------------------------------------------------------- @@ -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 // ------------------------------------------------------------------------- @@ -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}`); diff --git a/demo_config.js b/demo_config.js index f5969d8..ef8a667 100644 --- a/demo_config.js +++ b/demo_config.js @@ -2,6 +2,10 @@ // Each demo maps to a route (/) and a Pug template (views/.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.', @@ -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 })); diff --git a/react/.env.example b/react/.env.example new file mode 100644 index 0000000..673e776 --- /dev/null +++ b/react/.env.example @@ -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 diff --git a/react/.gitignore b/react/.gitignore new file mode 100644 index 0000000..21a4d97 --- /dev/null +++ b/react/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.env.* +!.env.example +*.local +*.tsbuildinfo diff --git a/react/README.md b/react/README.md new file mode 100644 index 0000000..5674413 --- /dev/null +++ b/react/README.md @@ -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 +``` diff --git a/react/index.html b/react/index.html new file mode 100644 index 0000000..7b6ec49 --- /dev/null +++ b/react/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + Castle React demo + + +
+ + + diff --git a/react/package-lock.json b/react/package-lock.json new file mode 100644 index 0000000..4f29fb7 --- /dev/null +++ b/react/package-lock.json @@ -0,0 +1,2107 @@ +{ + "name": "castle-node-example-react", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "castle-node-example-react", + "version": "1.0.0", + "dependencies": { + "@castleio/castle-js": "^2.8.4", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/node": "^22.19.19", + "@types/react": "^19.2.16", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^8.0.16" + } + }, + "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/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "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/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "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/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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/electron-to-chromium": { + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", + "dev": true, + "license": "ISC" + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "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/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "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/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "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/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.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" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/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" + } + } + } +} diff --git a/react/package.json b/react/package.json new file mode 100644 index 0000000..e2d1140 --- /dev/null +++ b/react/package.json @@ -0,0 +1,28 @@ +{ + "name": "castle-node-example-react", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "React + Vite front end showing how to integrate the Castle browser SDK (@castleio/castle-js) and talk to the Express demo backend.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@castleio/castle-js": "^2.8.4", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/node": "^22.19.19", + "@types/react": "^19.2.16", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^8.0.16" + } +} diff --git a/react/postcss.config.js b/react/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/react/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/react/src/App.tsx b/react/src/App.tsx new file mode 100644 index 0000000..90d426b --- /dev/null +++ b/react/src/App.tsx @@ -0,0 +1,19 @@ +import { useState } from 'react'; + +import type { EvaluateResponse } from './api.ts'; +import type { AccountUser } from './config.ts'; +import { AccountActions } from './components/AccountActions.tsx'; +import { ProfileForm } from './components/ProfileForm.tsx'; +import { ResultPanel } from './components/ResultPanel.tsx'; + +export function App({ user }: { user: AccountUser }) { + const [result, setResult] = useState(null); + + return ( +
+ + + {result && } +
+ ); +} diff --git a/react/src/api.ts b/react/src/api.ts new file mode 100644 index 0000000..f3e518d --- /dev/null +++ b/react/src/api.ts @@ -0,0 +1,51 @@ +export interface CastleResult { + risk?: number; + policy?: { action?: string; name?: string; id?: string }; + signals?: Record; + error?: string; + [key: string]: unknown; +} + +export interface EvaluateResponse { + api_endpoint: string; + payload_to_castle: Record; + result: CastleResult; + castle_type: string; + castle_status: string; +} + +async function postJSON(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`Request to ${url} failed with status ${res.status}`); + } + return (await res.json()) as T; +} + +export interface ProfileUpdateInput { + name: string; + email: string; + requestToken: string; +} + +/** Evaluate a profile update ($profile_update) through the Express backend. */ +export function evaluateProfileUpdate( + input: ProfileUpdateInput, +): Promise { + return postJSON('/evaluate_profile_update', { + name: input.name, + email: input.email, + request_token: input.requestToken, + }); +} + +/** Record a logout ($logout) via the non-blocking log endpoint. */ +export function evaluateLogout(requestToken: string): Promise { + return postJSON('/evaluate_logout', { + request_token: requestToken, + }); +} diff --git a/react/src/castle/CastleProvider.tsx b/react/src/castle/CastleProvider.tsx new file mode 100644 index 0000000..30fe18b --- /dev/null +++ b/react/src/castle/CastleProvider.tsx @@ -0,0 +1,78 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; +import { + configure, + createRequestToken, + custom, + type CustomParams, +} from '@castleio/castle-js'; + +interface CastleContextValue { + /** Whether a publishable key was provided and the SDK is configured. */ + readonly isConfigured: boolean; + /** + * Mint a fresh Castle request token. Always resolves: when the SDK is not + * configured it returns an empty string so callers can submit regardless. + */ + createRequestToken: () => Promise; + /** Send a custom event (Castle.custom). No-op when not configured. */ + trackCustom: (params: CustomParams) => void; +} + +const CastleContext = createContext(null); + +interface CastleProviderProps { + publishableKey?: string; + children: ReactNode; +} + +/** + * Configures the Castle browser SDK exactly once and exposes a small, typed + * API to the rest of the app. `configure` must run a single time for the + * lifetime of the page, so we guard it against React StrictMode's double mount. + */ +export function CastleProvider({ publishableKey, children }: CastleProviderProps) { + const isConfigured = Boolean(publishableKey); + const configuredRef = useRef(false); + + useEffect(() => { + if (!publishableKey || configuredRef.current) return; + configure({ pk: publishableKey }); + configuredRef.current = true; + }, [publishableKey]); + + const value = useMemo( + () => ({ + isConfigured, + createRequestToken: async () => { + if (!isConfigured) return ''; + try { + return await createRequestToken(); + } catch (err) { + console.error('Castle.createRequestToken failed', err); + return ''; + } + }, + trackCustom: (params) => { + if (isConfigured) custom(params); + }, + }), + [isConfigured], + ); + + return {children}; +} + +export function useCastle(): CastleContextValue { + const ctx = useContext(CastleContext); + if (!ctx) { + throw new Error('useCastle must be used within a '); + } + return ctx; +} diff --git a/react/src/components/AccountActions.tsx b/react/src/components/AccountActions.tsx new file mode 100644 index 0000000..147e4ee --- /dev/null +++ b/react/src/components/AccountActions.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; + +import { evaluateLogout, type EvaluateResponse } from '../api.ts'; +import { useCastle } from '../castle/CastleProvider.tsx'; +import type { AccountUser } from '../config.ts'; + +interface AccountActionsProps { + user: AccountUser; + onResult: (response: EvaluateResponse) => void; +} + +export function AccountActions({ user, onResult }: AccountActionsProps) { + const { createRequestToken, trackCustom } = useCastle(); + const [loggingOut, setLoggingOut] = useState(false); + + async function handleLogout() { + setLoggingOut(true); + try { + // The logout is recorded with a fresh token via the log endpoint. + const requestToken = await createRequestToken(); + const response = await evaluateLogout(requestToken); + onResult(response); + } finally { + setLoggingOut(false); + } + } + + return ( +
+
react · session
+

Session

+

+ Fire a custom event (Castle.custom) or log out + ($logout via the non-blocking log endpoint). +

+ +
+ + +
+
+ ); +} diff --git a/react/src/components/ProfileForm.tsx b/react/src/components/ProfileForm.tsx new file mode 100644 index 0000000..b55c757 --- /dev/null +++ b/react/src/components/ProfileForm.tsx @@ -0,0 +1,86 @@ +import { useState, type FormEvent } from 'react'; + +import { evaluateProfileUpdate, type EvaluateResponse } from '../api.ts'; +import { useCastle } from '../castle/CastleProvider.tsx'; +import type { AccountUser } from '../config.ts'; + +interface ProfileFormProps { + user: AccountUser; + onResult: (response: EvaluateResponse) => void; +} + +export function ProfileForm({ user, onResult }: ProfileFormProps) { + const { createRequestToken, isConfigured } = useCastle(); + + const [name, setName] = useState(user.name); + const [email, setEmail] = useState(user.email); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setError(null); + + try { + // Mint a fresh request token for this update, then hand it to the backend + // which forwards it to Castle as a $profile_update event. + const requestToken = await createRequestToken(); + const response = await evaluateProfileUpdate({ name, email, requestToken }); + onResult(response); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSubmitting(false); + } + } + + return ( +
+
react · workflow
+

Update your profile

+

+ Signed in as {user.email}. Changing your name or email sends + a $profile_update event to Castle and shows the verdict. +

+ + {!isConfigured && ( +

+ No publishable key configured — the update is sent without a token. +

+ )} + +
+
+ + setName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + /> +
+ + {error &&

{error}

} + +
+ +
+
+
+ ); +} diff --git a/react/src/components/ResultPanel.tsx b/react/src/components/ResultPanel.tsx new file mode 100644 index 0000000..bd4b77d --- /dev/null +++ b/react/src/components/ResultPanel.tsx @@ -0,0 +1,73 @@ +import type { EvaluateResponse } from '../api.ts'; + +const ACTION_CLASS: Record = { + allow: 'verdict-allow', + challenge: 'verdict-challenge', + deny: 'verdict-deny', +}; + +function Verdict({ response }: { response: EvaluateResponse }) { + const action = response.result.policy?.action; + if (!action) return null; + + const score = response.result.risk; + const signals = Object.keys(response.result.signals ?? {}); + + return ( +
+
+ {action} + {typeof score === 'number' && ( + + risk score {score.toFixed(2)} + + )} +
+ {signals.length > 0 && ( +
+ {signals.map((name) => ( + + {name} + + ))} +
+ )} +
+ ); +} + +export function ResultPanel({ response }: { response: EvaluateResponse }) { + return ( +
+
result
+ +
+ Castle endpoint /{response.api_endpoint} ·{' '} + + {response.castle_type} / {response.castle_status} + +
+ + {response.result.error ? ( +
+ error + {response.result.error} +
+ ) : ( + + )} + +
+ Payload sent to Castle +
+
+        {JSON.stringify(response.payload_to_castle, null, 2)}
+      
+ +
+ Response from Castle +
+
{JSON.stringify(response.result, null, 2)}
+
+ ); +} diff --git a/react/src/config.ts b/react/src/config.ts new file mode 100644 index 0000000..4d95dfe --- /dev/null +++ b/react/src/config.ts @@ -0,0 +1,42 @@ +export interface AccountUser { + id?: string; + email: string; + name: string; +} + +export interface AccountConfig { + pk?: string; + user: AccountUser; +} + +declare global { + interface Window { + CASTLE_ACCOUNT?: { + pk?: string | null; + user?: Partial | null; + }; + } +} + +const DEFAULT_USER: AccountUser = { + id: undefined, + email: 'clark.kent@dailyplanet.com', + name: 'Clark Kent', +}; + +/** + * Read the config injected by the server-rendered shell, falling back to the + * Vite env / defaults so the app also runs standalone (`npm run dev`). + */ +export function readAccountConfig(): AccountConfig { + const injected = typeof window !== 'undefined' ? window.CASTLE_ACCOUNT : undefined; + + return { + pk: injected?.pk ?? import.meta.env.VITE_CASTLE_PK, + user: { + id: injected?.user?.id ?? DEFAULT_USER.id, + email: injected?.user?.email ?? DEFAULT_USER.email, + name: injected?.user?.name ?? DEFAULT_USER.name, + }, + }; +} diff --git a/react/src/index.css b/react/src/index.css new file mode 100644 index 0000000..1724387 --- /dev/null +++ b/react/src/index.css @@ -0,0 +1,104 @@ +@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(124, 92, 255, 0.12), + transparent 60% + ); + } + + a { + @apply text-accent no-underline hover:underline; + } + + code { + @apply rounded border border-border bg-surface-2 px-1.5 py-0.5 font-mono text-[0.86em]; + } +} + +.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; +} + +.tag { + @apply rounded-full border border-accent/40 bg-accent/10 px-2 py-0.5 text-xs font-semibold text-accent; +} + +.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(124, 92, 255, 0.14); +} + +.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 disabled:cursor-not-allowed disabled:opacity-50; +} + +.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; +} + +pre.json { + @apply m-0 overflow-auto rounded-lg border border-border bg-bg-soft p-4 font-mono text-[0.85rem] leading-normal text-ink; +} + +.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-allow { + @apply border-success/40 bg-success/10; +} +.verdict-allow .verdict-action { + @apply bg-success text-bg; +} + +.verdict-challenge { + @apply border-challenge/40 bg-challenge/10; +} +.verdict-challenge .verdict-action { + @apply bg-challenge text-bg; +} + +.verdict-deny { + @apply border-danger/40 bg-danger/10; +} +.verdict-deny .verdict-action { + @apply bg-danger text-white; +} + +.chip { + @apply rounded-full border border-border bg-bg-soft px-2 py-0.5 font-mono text-xs text-muted; +} diff --git a/react/src/main.tsx b/react/src/main.tsx new file mode 100644 index 0000000..12ff43d --- /dev/null +++ b/react/src/main.tsx @@ -0,0 +1,20 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App.tsx'; +import { CastleProvider } from './castle/CastleProvider.tsx'; +import { readAccountConfig } from './config.ts'; +import './index.css'; + +// Config is injected by the Express/Pug shell on the /account page +// (window.CASTLE_ACCOUNT), with import.meta.env as a fallback for standalone +// React development. +const config = readAccountConfig(); + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/react/src/vite-env.d.ts b/react/src/vite-env.d.ts new file mode 100644 index 0000000..92ae378 --- /dev/null +++ b/react/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + /** Castle publishable key (pk_...), used by the browser SDK. */ + readonly VITE_CASTLE_PK?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/react/tailwind.config.js b/react/tailwind.config.js new file mode 100644 index 0000000..d762308 --- /dev/null +++ b/react/tailwind.config.js @@ -0,0 +1,30 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + bg: '#0b0e14', + 'bg-soft': '#11151f', + surface: '#151a23', + 'surface-2': '#1b2230', + border: '#232b39', + 'border-soft': '#1c2330', + ink: '#e6e9ef', + muted: '#9aa4b2', + accent: '#7c5cff', + 'accent-hover': '#6b4cf0', + success: '#2ecc71', + challenge: '#ffbf47', + danger: '#ff5c7c', + }, + 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 10px 30px rgba(0, 0, 0, 0.35)' }, + }, + }, + plugins: [], +}; diff --git a/react/tsconfig.json b/react/tsconfig.json new file mode 100644 index 0000000..1e64b0a --- /dev/null +++ b/react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/react/vite.config.ts b/react/vite.config.ts new file mode 100644 index 0000000..f00f5b8 --- /dev/null +++ b/react/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// This app is built and served by the Express demo backend from /react-app on +// the post-login /account page. `base` makes asset URLs resolve under that +// mount, and the fixed output filenames let the Pug shell reference the bundle +// without parsing a manifest. +// +// For standalone React development you can still run `npm run dev` and point a +// browser at http://localhost:5173/react-app/; the API routes are proxied to +// the Express backend below. +const BACKEND = process.env.VITE_BACKEND_URL || 'http://localhost:4006'; +const API_ROUTES = ['/evaluate_profile_update', '/evaluate_login']; + +export default defineConfig({ + base: '/react-app/', + plugins: [react()], + build: { + rollupOptions: { + output: { + entryFileNames: 'assets/account.js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/account.[ext]', + }, + }, + }, + server: { + port: 5173, + proxy: Object.fromEntries( + API_ROUTES.map((route) => [route, { target: BACKEND, changeOrigin: true }]), + ), + }, +}); diff --git a/readme.md b/readme.md index b677247..01e7bae 100644 --- a/readme.md +++ b/readme.md @@ -4,14 +4,23 @@ This project demonstrates key components of several essential Castle workflows. ## What's demonstrated -- **login** – `risk` (successful login) and `filter` (failed login) endpoints, with the verdict (allow / challenge / deny), risk score and signals surfaced in the UI -- **password reset** – the non-blocking `log` endpoint +The app walks through a full user lifecycle. Every request mints a fresh Castle +request token in the browser (`Castle.createRequestToken()`) and forwards it to +the backend. + +Server-rendered (Pug) pages: + +- **sign up** – `$registration` to `risk` (a new email) or `filter` (an email that already exists) +- **login** – `$login` to `risk` (successful) or `filter` (failed), with the verdict (allow / challenge / deny), risk score and signals surfaced in the UI +- **password reset** – `$password_reset` via the non-blocking `log` endpoint - **lists** – the Lists API (`createList`, `fetchAllLists`) - **privacy** – the Privacy API (`requestUserData`, `deleteUserData`) -- **events** – the Events API (`eventsSchema`, `queryEvents`) -The browser SDK is also used to track page views (`Castle.page()`) and send an -ad-hoc custom event (`Castle.custom()`). +Post-login React `/account` page (see [React integration](#react-integration)): + +- **profile update** – `$profile_update` to `risk` +- **custom event** – `Castle.custom()` (only available once signed in) +- **logout** – `$logout` via the non-blocking `log` endpoint ## Screenshots @@ -19,6 +28,15 @@ ad-hoc custom event (`Castle.custom()`). | ---- | ----- | | ![Home](docs/screenshots/home.png) | ![Login](docs/screenshots/login.png) | +## React integration + +The post-login `/account` page is a **React + Vite + TypeScript** app +([`react/`](react/)) served by Express. It integrates `@castleio/castle-js` +through a `CastleProvider` / `useCastle()` hook (configured once) and mints a +request token for each action — profile update, custom event and logout. Build +it with `npm install --prefix react && npm run build --prefix react`; see +[`react/README.md`](react/README.md) for details. + ## Prerequisites You'll need a Castle tenant to run this app against. If you don't already have one, you can start a free trial at https://castle.io. diff --git a/test/app.test.js b/test/app.test.js index 7bb2b4e..76f5660 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -31,8 +31,6 @@ function stubbedCastle(overrides = {}) { fetchAllLists: { total_count: 1, data: [{ id: 'list_1' }] }, requestUserData: { status: 'pending' }, deleteUserData: { status: 'pending' }, - eventsSchema: { fields: [] }, - queryEvents: { data: [] }, }; Object.entries({ ...defaults, ...overrides }).forEach(([method, value]) => { jest.spyOn(castle, method).mockResolvedValue(value); @@ -58,7 +56,16 @@ describe('page routes', () => { expect(res.text).toContain('Log in'); }); - test.each(['password_reset', 'lists', 'privacy', 'events'])( + test('GET /account renders the React account shell', async () => { + const res = await request(app).get('/account'); + expect(res.status).toBe(200); + expect(res.text).toContain('Your account'); + // config for the React app is injected, not the global SDK chrome + expect(res.text).toContain('window.CASTLE_ACCOUNT'); + expect(res.text).not.toContain('/vendor/castle-js/castle.browser.js'); + }); + + test.each(['signup', 'password_reset', 'lists', 'privacy'])( 'GET /%s renders', async (name) => { const res = await request(app).get('/' + name); @@ -134,6 +141,87 @@ describe('POST /evaluate_login', () => { }); }); +describe('POST /evaluate_signup', () => { + test('a new email is risk-assessed as $registration', async () => { + const castle = stubbedCastle(); + const res = await request(buildApp(castle)).post('/evaluate_signup').send({ + name: 'Lois Lane', + email: 'lois.lane@dailyplanet.com', + password: 'whatever', + request_token: 'tok', + }); + + expect(res.status).toBe(200); + expect(res.body.api_endpoint).toBe('risk'); + expect(res.body.castle_type).toBe('$registration'); + expect(res.body.castle_status).toBe('$succeeded'); + expect(castle.risk).toHaveBeenCalledTimes(1); + expect(res.body.payload_to_castle).not.toHaveProperty('context'); + }); + + test('an already-registered email goes to filter as $failed', async () => { + const castle = stubbedCastle(); + const res = await request(buildApp(castle)).post('/evaluate_signup').send({ + name: 'Clark Kent', + email: VALID_USERNAME, + password: 'whatever', + request_token: 'tok', + }); + + expect(res.body.api_endpoint).toBe('filter'); + expect(res.body.castle_status).toBe('$failed'); + expect(castle.filter).toHaveBeenCalledTimes(1); + expect(castle.risk).not.toHaveBeenCalled(); + }); +}); + +describe('POST /evaluate_logout', () => { + test('records $logout via the log endpoint', async () => { + const castle = stubbedCastle(); + const res = await request(buildApp(castle)) + .post('/evaluate_logout') + .send({ request_token: 'tok' }); + + expect(res.status).toBe(200); + expect(res.body.api_endpoint).toBe('log'); + expect(res.body.castle_type).toBe('$logout'); + expect(res.body.result).toEqual({ logged: true }); + expect(castle.log).toHaveBeenCalledTimes(1); + }); +}); + +describe('POST /evaluate_profile_update', () => { + test('sends a $profile_update to risk and echoes the new details', async () => { + const castle = stubbedCastle(); + const res = await request(buildApp(castle)).post('/evaluate_profile_update').send({ + name: 'Kal-El', + email: 'kal.el@dailyplanet.com', + request_token: 'tok', + }); + + expect(res.status).toBe(200); + expect(res.body.api_endpoint).toBe('risk'); + expect(res.body.castle_type).toBe('$profile_update'); + expect(castle.risk).toHaveBeenCalledTimes(1); + expect(res.body.payload_to_castle).not.toHaveProperty('context'); + expect(res.body.payload_to_castle.user.name).toBe('Kal-El'); + expect(res.body.payload_to_castle.user.email).toBe('kal.el@dailyplanet.com'); + expect(res.body.result.policy.action).toBe('allow'); + }); + + test('surfaces API errors without crashing', async () => { + const castle = stubbedCastle(); + castle.risk.mockRejectedValue(new APIError('Responded with 401 code')); + + const res = await request(buildApp(castle)) + .post('/evaluate_profile_update') + .send({ name: 'Kal-El', email: 'kal.el@dailyplanet.com', request_token: 'tok' }); + + expect(res.status).toBe(200); + expect(res.body.result.error).toMatch(/401/); + }); +}); + describe('POST /evaluate_new_password', () => { test('a new (different) password logs $succeeded', async () => { const castle = stubbedCastle(); @@ -197,28 +285,6 @@ describe('account-level APIs', () => { }); }); - test('POST /events_schema fetches the schema', async () => { - const castle = stubbedCastle(); - const res = await request(buildApp(castle)).post('/events_schema').send({}); - - expect(res.body.api_endpoint).toBe('events/schema'); - expect(castle.eventsSchema).toHaveBeenCalledTimes(1); - }); - - test('POST /query_events builds a filter from the request body', async () => { - const castle = stubbedCastle(); - const res = await request(buildApp(castle)) - .post('/query_events') - .send({ field: 'name', op: '$eq', value: '$login' }); - - expect(res.body.api_endpoint).toBe('events/query'); - expect(castle.queryEvents).toHaveBeenCalledWith( - expect.objectContaining({ - filters: [{ field: 'name', op: '$eq', value: '$login' }], - }) - ); - }); - test('account-level errors are surfaced as result.error', async () => { const castle = stubbedCastle(); castle.createList.mockRejectedValue(new APIError('Responded with 401 code')); diff --git a/test/sdk-integration.test.js b/test/sdk-integration.test.js index d87ee5a..8ca323a 100644 --- a/test/sdk-integration.test.js +++ b/test/sdk-integration.test.js @@ -142,6 +142,42 @@ describe('risk / filter request building', () => { expect(fetch.calls[0].pathname).toBe('/v1/filter'); expect(fetch.calls[0].body).toMatchObject({ type: '$login', status: '$failed' }); }); + + test('a new registration POSTs $registration to /v1/risk', async () => { + const fetch = recordingFetch({ + 'POST /v1/risk': () => httpResponse(200, { policy: { action: 'allow' }, risk: 0.2 }), + }); + + const res = await request(buildApp(makeCastle(fetch))) + .post('/evaluate_signup') + .send({ name: 'Lois Lane', email: 'lois.lane@dailyplanet.com', request_token: 'tok' }); + + expect(res.body.api_endpoint).toBe('risk'); + expect(fetch.calls[0].pathname).toBe('/v1/risk'); + expect(fetch.calls[0].body).toMatchObject({ + type: '$registration', + status: '$succeeded', + request_token: 'tok', + user: { email: 'lois.lane@dailyplanet.com', name: 'Lois Lane' }, + }); + }); + + test('a profile update POSTs $profile_update to /v1/risk', async () => { + const fetch = recordingFetch({ + 'POST /v1/risk': () => httpResponse(200, { policy: { action: 'allow' }, risk: 0.1 }), + }); + + const res = await request(buildApp(makeCastle(fetch))) + .post('/evaluate_profile_update') + .send({ name: 'Kal-El', email: 'kal.el@dailyplanet.com', request_token: 'tok' }); + + expect(res.body.api_endpoint).toBe('risk'); + expect(fetch.calls[0].body).toMatchObject({ + type: '$profile_update', + request_token: 'tok', + user: { name: 'Kal-El', email: 'kal.el@dailyplanet.com' }, + }); + }); }); describe('error mapping', () => { @@ -252,6 +288,21 @@ describe('log (fire-and-forget)', () => { expect(fetch.calls[0].pathname).toBe('/v1/log'); expect(fetch.calls[0].body).toMatchObject({ type: '$password_reset' }); }); + + test('logout POSTs $logout to /v1/log', async () => { + const fetch = recordingFetch({ 'POST /v1/log': () => httpResponse(200, {}) }); + + const res = await request(buildApp(makeCastle(fetch))) + .post('/evaluate_logout') + .send({ request_token: 'tok' }); + + expect(res.body.api_endpoint).toBe('log'); + expect(res.body.result).toEqual({ logged: true }); + + await flush(); + expect(fetch.calls[0].pathname).toBe('/v1/log'); + expect(fetch.calls[0].body).toMatchObject({ type: '$logout', status: '$succeeded' }); + }); }); describe('Lists API', () => { @@ -315,32 +366,3 @@ describe('Privacy API', () => { }); }); -describe('Events API', () => { - test('events schema GETs /v1/events/schema', async () => { - const fetch = recordingFetch({ - 'GET /v1/events/schema': () => httpResponse(200, { fields: [] }), - }); - - const res = await request(buildApp(makeCastle(fetch))).post('/events_schema').send({}); - - expect(res.body.api_endpoint).toBe('events/schema'); - expect(fetch.calls[0]).toMatchObject({ method: 'GET', pathname: '/v1/events/schema' }); - }); - - test('query events POSTs filters to /v1/events/query', async () => { - const fetch = recordingFetch({ - 'POST /v1/events/query': () => httpResponse(200, { data: [] }), - }); - - const res = await request(buildApp(makeCastle(fetch))) - .post('/query_events') - .send({ field: 'name', op: '$eq', value: '$login' }); - - expect(res.body.api_endpoint).toBe('events/query'); - expect(fetch.calls[0]).toMatchObject({ method: 'POST', pathname: '/v1/events/query' }); - expect(fetch.calls[0].body).toMatchObject({ - filters: [{ field: 'name', op: '$eq', value: '$login' }], - sort: { field: 'created_at', order: 'desc' }, - }); - }); -}); diff --git a/views/account.pug b/views/account.pug new file mode 100644 index 0000000..6141332 --- /dev/null +++ b/views/account.pug @@ -0,0 +1,29 @@ +extends base + +block head + if react_built + link(href=react_css rel="stylesheet") + //- Hand the React app its config without string interpolation in markup. + script(type="application/json" id="castle-account-config")!= JSON.stringify({ pk: castle_pk || null, user: account_user }) + script. + window.CASTLE_ACCOUNT = JSON.parse(document.getElementById('castle-account-config').textContent); + +block content + section.mb-6 + span.tag castle browser sdk · react + h1(class="text-[1.6rem] mt-3") Your account + p.text-muted You're signed in. This page is a React app mounted inside the Express demo — use it to test the React integration. + + if react_built + #root + else + .card + .eyebrow react bundle not built + p.text-muted(style="margin-top:.4rem;") Build the React app to enable this page: + pre.json npm install --prefix react + | + | npm run build --prefix react + +block scripts + if react_built + script(type="module" src=react_js) diff --git a/views/base.pug b/views/base.pug index 82ab8d6..9bbcdc6 100644 --- a/views/base.pug +++ b/views/base.pug @@ -11,15 +11,20 @@ html(lang="en") link(href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet") link(href="/static/styles.css" rel="stylesheet") - script(src="/vendor/castle-js/castle.browser.js") - //- Server-rendered config, read by the browser without string interpolation. - script(type="application/json" id="castle-config")!= JSON.stringify({ pk: castle_pk || null, valid_username: valid_username || null, valid_password: valid_password || null, invalid_password: invalid_password || null }) - script. - window.CASTLE_DEMO = JSON.parse(document.getElementById('castle-config').textContent); - if (window.Castle && window.CASTLE_DEMO.pk) { - Castle.configure({ pk: window.CASTLE_DEMO.pk }); - } - script(src="/static/app.js" defer) + block head + + //- The server-rendered pages use the global browser SDK directly. The + //- React /account page bundles its own SDK instance, so it skips this. + if !account + script(src="/vendor/castle-js/castle.browser.js") + //- Server-rendered config, read by the browser without string interpolation. + script(type="application/json" id="castle-config")!= JSON.stringify({ pk: castle_pk || null, valid_username: valid_username || null, valid_password: valid_password || null, invalid_password: invalid_password || null }) + script. + window.CASTLE_DEMO = JSON.parse(document.getElementById('castle-config').textContent); + if (window.Castle && window.CASTLE_DEMO.pk) { + Castle.configure({ pk: window.CASTLE_DEMO.pk }); + } + script(src="/static/app.js" defer) body nav.navbar @@ -31,6 +36,7 @@ html(lang="en") .nav-links each demo in demo_list a(href="/" + demo.url)= demo.friendly_name + a(href="/account") account a(href="https://github.com/castle/castle-node-example" target="_blank" rel="noopener") GitHub a(href="https://docs.castle.io" target="_blank" rel="noopener") Docs diff --git a/views/events.pug b/views/events.pug deleted file mode 100644 index 05aa2e2..0000000 --- a/views/events.pug +++ /dev/null @@ -1,43 +0,0 @@ -extends demo - -block ui - .btn-row(style="margin-top:0;") - button.btn(onclick="eventsSchema()") Fetch schema - - hr.my-5.border-border - - .field - label(for="field") field - input.input(type="text" id="field" value="name") - .field - label(for="op") op - input.input(type="text" id="op" value="$eq") - .field - label(for="value") value - input.input(type="text" id="value" value="$login") - .btn-row - button.btn.btn-primary(onclick="queryEvents()") Query events - -block desc - p The - strong Events - | API lets you inspect your event schema ( - code /events/schema - | ) and query recorded events ( - code /events/query - | ). - p A valid Castle API secret is required for these calls to succeed. - -block scripts - script. - function eventsSchema() { - postJSON("/events_schema", {}).then(renderCastleResponse); - } - - function queryEvents() { - postJSON("/query_events", { - field: document.getElementById("field").value, - op: document.getElementById("op").value, - value: document.getElementById("value").value, - }).then(renderCastleResponse); - } diff --git a/views/login.pug b/views/login.pug index db0bfb1..ba114a3 100644 --- a/views/login.pug +++ b/views/login.pug @@ -20,7 +20,7 @@ block ui button.btn.btn-primary(onclick="login()") Log in .form-links a(href="/password_reset") Forgot your password? - a(href="#" onclick="trackCustomEvent('$custom'); return false;") Send a custom event + a(href="/signup") Create an account block desc p A login attempt has three common outcomes, and each maps to a different Castle endpoint: @@ -71,6 +71,18 @@ block scripts email: document.getElementById("email").value, password: document.getElementById("password").value, request_token: requestToken, - }).then(renderCastleResponse); + }).then(function (data) { + renderCastleResponse(data); + // On an allowed login, offer to continue to the React account page. + var action = data.result && data.result.policy && data.result.policy.action; + if (action === "allow") { + var results = document.getElementById("results"); + var wrap = document.createElement("div"); + wrap.className = "result-block"; + wrap.innerHTML = + 'Continue to your account →'; + results.appendChild(wrap); + } + }); }); } diff --git a/views/signup.pug b/views/signup.pug new file mode 100644 index 0000000..6965f57 --- /dev/null +++ b/views/signup.pug @@ -0,0 +1,78 @@ +extends demo + +block ui + .btn-row(style="margin-top:0;") + button.btn.btn-ghost(onclick="fillForm('new')") new user + button.btn.btn-ghost(onclick="fillForm('existing')") existing email + + div(style="margin-top:1.2rem;") + .field + label(for="name") name + input.input(type="text" id="name" autocomplete="off") + .field + label(for="email") email + input.input(type="text" id="email" autocomplete="off") + .field + label(for="password") password + input.input(type="password" id="password") + .btn-row + button.btn.btn-primary(onclick="signup()") Create account + .form-links + a(href="/login") Already have an account? Log in + +block desc + p A registration is a sensitive action, so it is risk-assessed: + ol.list-decimal.pl-5.space-y-1 + li + strong a new email + | → + code $registration / $succeeded + | sent to + code /risk + | ; act on the verdict (allow, challenge, deny). + li + strong an email that already exists + | → + code $registration / $failed + | sent to + code /filter + | . + +block scripts + script. + function fillForm(state) { + var demo = window.CASTLE_DEMO || {}; + var name = document.getElementById("name"); + var email = document.getElementById("email"); + var password = document.getElementById("password"); + if (state === "existing") { + name.value = "Clark Kent"; + email.value = demo.valid_username || ""; + } else { + name.value = "Lois Lane"; + email.value = "lois.lane@dailyplanet.com"; + } + password.value = demo.valid_password || ""; + } + + function signup() { + withRequestToken(function (requestToken) { + postJSON("/evaluate_signup", { + name: document.getElementById("name").value, + email: document.getElementById("email").value, + password: document.getElementById("password").value, + request_token: requestToken, + }).then(function (data) { + renderCastleResponse(data); + var action = data.result && data.result.policy && data.result.policy.action; + if (action === "allow") { + var results = document.getElementById("results"); + var wrap = document.createElement("div"); + wrap.className = "result-block"; + wrap.innerHTML = + 'Continue to your account →'; + results.appendChild(wrap); + } + }); + }); + }