From 23df3b064cb507fbd276ff0288e36edf08929a30 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Tue, 9 Jun 2026 17:07:41 +0200 Subject: [PATCH] Evaluate registration and login as anonymous-then-authenticated flows Send registration to the Filter API with the form params: $attempted for a new email and $failed (resolved via matching_user_id) for an email that already exists. Run login as a Filter $attempted call followed, on the same request token, by a Risk $succeeded call on success or a Filter $failed call on a wrong password or unknown user. The login demo renders each step of the sequence. --- app.js | 129 ++++++++++++++++++----------------- demo_config.js | 4 +- package-lock.json | 30 -------- readme.md | 4 +- static/app.js | 15 ++++ test/app.test.js | 62 ++++++++++------- test/frontend.test.js | 31 +++++++++ test/sdk-integration.test.js | 81 +++++++++++++--------- views/login.pug | 28 ++++---- views/signup.pug | 18 ++--- 10 files changed, 232 insertions(+), 170 deletions(-) diff --git a/app.js b/app.js index bba9e62..59589db 100644 --- a/app.js +++ b/app.js @@ -119,7 +119,7 @@ function buildApp(castle = require('./castle')) { }; // a default value reused across the login / password-reset demos - let registeredAt = '2020-02-23T22:28:55.387Z'; + const registeredAt = '2020-02-23T22:28:55.387Z'; // ------------------------------------------------------------------------- // Page routes @@ -203,33 +203,34 @@ function buildApp(castle = require('./castle')) { }); // ------------------------------------------------------------------------- - // Risk / Filter (registration) + // Filter (registration) // ------------------------------------------------------------------------- + // A registration is evaluated before the account exists, so it is anonymous + // activity sent to /filter with the form params (email/phone only). A brand- + // new email is an attempt; an email that already belongs to a user is a failed + // registration, resolved to that user via matching_user_id. app.post('/evaluate_signup', async (req, res) => { - const { name, email, request_token } = req.body; + const { 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 castleStatus = alreadyRegistered ? '$failed' : '$attempted'; const payloadToCastle = { type: castleType, status: castleStatus, - user: { id: process.env.valid_user_id, email, name }, + params: { email }, request_token, context: buildContext(req), }; + if (alreadyRegistered) { + payloadToCastle.matching_user_id = process.env.valid_user_id; + } let result; try { - result = - apiEndpoint === 'risk' - ? await castle.risk(payloadToCastle) - : await castle.filter(payloadToCastle); + result = await castle.filter(payloadToCastle); } catch (err) { result = errorResult(err); } @@ -237,7 +238,7 @@ function buildApp(castle = require('./castle')) { const { context, ...echoedPayload } = payloadToCastle; res.json({ - api_endpoint: apiEndpoint, + api_endpoint: 'filter', payload_to_castle: echoedPayload, result, castle_type: castleType, @@ -246,67 +247,73 @@ function buildApp(castle = require('./castle')) { }); // ------------------------------------------------------------------------- - // Risk / Filter (login) + // Filter -> Risk (login) // ------------------------------------------------------------------------- + // A login reuses one request token across two calls: first Filter the attempt + // while the visitor is still anonymous, then — on success — assess the + // authenticated user with Risk. A failed attempt stays on Filter. app.post('/evaluate_login', async (req, res) => { const { email, password, request_token } = req.body; - const castleType = '$login'; - let userId; - let castleStatus; - let apiEndpoint; - // check validity of username + password combo - if (email === process.env.valid_username) { - userId = process.env.valid_user_id; - - if (password === process.env.valid_password) { - castleStatus = '$succeeded'; - apiEndpoint = 'risk'; - } else { - castleStatus = '$failed'; - apiEndpoint = 'filter'; + const runStep = async (apiEndpoint, castleStatus, fields) => { + const payloadToCastle = { + type: castleType, + status: castleStatus, + ...fields, + request_token, + context: buildContext(req), + }; + + let result; + try { + result = + apiEndpoint === 'risk' + ? await castle.risk(payloadToCastle) + : await castle.filter(payloadToCastle); + } catch (err) { + result = errorResult(err); } - } else { - apiEndpoint = 'filter'; - castleStatus = '$failed'; - userId = null; - registeredAt = null; - } - const payloadToCastle = { - type: castleType, - status: castleStatus, - user: { id: userId, email }, - request_token, - context: buildContext(req), + // context is large and noisy; don't echo it back to the browser. + const { context, ...echoedPayload } = payloadToCastle; + return { + api_endpoint: apiEndpoint, + payload_to_castle: echoedPayload, + result, + castle_type: castleType, + castle_status: castleStatus, + }; }; - if (registeredAt) { - payloadToCastle.user.registered_at = registeredAt; - } + // Step 1 — always filter the attempt up front (anonymous -> params). + const steps = [await runStep('filter', '$attempted', { params: { email } })]; - let result; - try { - result = - apiEndpoint === 'risk' - ? await castle.risk(payloadToCastle) - : await castle.filter(payloadToCastle); - } catch (err) { - result = errorResult(err); + // Step 2 — the outcome, on the same request token. + if ( + email === process.env.valid_username && + password === process.env.valid_password + ) { + steps.push( + await runStep('risk', '$succeeded', { + user: { + id: process.env.valid_user_id, + email, + registered_at: registeredAt, + }, + }) + ); + } else { + const fields = { params: { email } }; + // A known email with a wrong password resolves to the existing user. + if (email === process.env.valid_username) { + fields.matching_user_id = process.env.valid_user_id; + } + steps.push(await runStep('filter', '$failed', fields)); } - // context is large and noisy; don't echo it back to the browser. - const { context, ...echoedPayload } = payloadToCastle; - - res.json({ - api_endpoint: apiEndpoint, - payload_to_castle: echoedPayload, - result, - castle_type: castleType, - castle_status: castleStatus, - }); + res.json({ steps }); }); // ------------------------------------------------------------------------- diff --git a/demo_config.js b/demo_config.js index e72da21..4d1e73f 100644 --- a/demo_config.js +++ b/demo_config.js @@ -4,11 +4,11 @@ const demos = { signup: { friendly_name: 'sign up', - blurb: 'Evaluate a registration ($registration) with the risk endpoint.', + blurb: 'Filter a registration ($registration) before the account exists.', }, login: { friendly_name: 'login', - blurb: 'Evaluate a login with the risk and filter endpoints.', + blurb: 'Filter the attempt, then assess a successful login with Risk.', wsd: 'https://www.websequencediagrams.com/files/render?link=Q9WYp8rNThVZhA1inf2FSLfjChYZTdHXyGB9zqvMNpsaAvKvJPARgo5LI5fM5K4D', }, password_reset: { diff --git a/package-lock.json b/package-lock.json index 6dc6d4c..a98b65d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1569,9 +1569,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1586,9 +1583,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1603,9 +1597,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1620,9 +1611,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1637,9 +1625,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1654,9 +1639,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1671,9 +1653,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1688,9 +1667,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1705,9 +1681,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1722,9 +1695,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/readme.md b/readme.md index 21f7163..c84836f 100644 --- a/readme.md +++ b/readme.md @@ -11,8 +11,8 @@ the backend, which calls Castle and acts on the verdict. Server-rendered 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 +- **sign up** – `$registration` to `filter` (anonymous, so the email goes in `params`): `$attempted` for a new email, `$failed` (resolved via `matching_user_id`) for an email that already exists +- **login** – `$login` reusing one request token across two calls: `filter` `$attempted` first, then `risk` `$succeeded` on success or `filter` `$failed` (wrong password / unknown user); the verdict (allow / challenge / deny), risk score and signals are surfaced per step - **password reset** – `$password_reset` via the non-blocking `log` endpoint - **lists** – the Lists API (`createList`, `fetchAllLists`) - **privacy** – the Privacy API (`requestUserData`, `deleteUserData`) diff --git a/static/app.js b/static/app.js index 6462326..8b65cb0 100644 --- a/static/app.js +++ b/static/app.js @@ -143,6 +143,21 @@ function renderCastleResponse(data) { showResultsCard(); } +// Renders an ordered sequence of Castle calls (e.g. the login Filter -> Risk +// flow), one endpoint/verdict/payload/result block per step. +function renderCastleSteps(steps) { + clearResults(); + (steps || []).forEach(function (step) { + if (step.api_endpoint) addEndpointBadge(step.api_endpoint); + addVerdictBanner(step.result); + if (step.payload_to_castle) addJSONBlock("Payload sent to Castle", step.payload_to_castle); + if (step.result !== undefined && step.result !== null) { + addJSONBlock("Response from Castle", step.result); + } + }); + showResultsCard(); +} + // Tell Castle which page the user is on. Safe no-op if the browser SDK or the // publishable key is unavailable. function trackPage() { diff --git a/test/app.test.js b/test/app.test.js index 44fbbfe..89bc19c 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -81,7 +81,7 @@ describe('page routes', () => { }); describe('POST /evaluate_login', () => { - test('valid username + valid password goes to risk', async () => { + test('filters the attempt first, then risk-assesses a valid login', async () => { const castle = stubbedCastle(); const res = await request(buildApp(castle)).post('/evaluate_login').send({ email: VALID_USERNAME, @@ -90,17 +90,24 @@ describe('POST /evaluate_login', () => { }); expect(res.status).toBe(200); - expect(res.body.api_endpoint).toBe('risk'); - expect(res.body.castle_status).toBe('$succeeded'); + expect(res.body.steps).toHaveLength(2); + + const [attempt, outcome] = res.body.steps; + expect(attempt.api_endpoint).toBe('filter'); + expect(attempt.castle_status).toBe('$attempted'); + expect(attempt.payload_to_castle.params.email).toBe(VALID_USERNAME); + expect(attempt.payload_to_castle).not.toHaveProperty('context'); + + expect(outcome.api_endpoint).toBe('risk'); + expect(outcome.castle_status).toBe('$succeeded'); + expect(outcome.payload_to_castle.user.id).toBe(VALID_USER_ID); + expect(outcome.result.policy.action).toBe('allow'); + + expect(castle.filter).toHaveBeenCalledTimes(1); expect(castle.risk).toHaveBeenCalledTimes(1); - expect(castle.filter).not.toHaveBeenCalled(); - // context must not be echoed back to the browser - expect(res.body.payload_to_castle).not.toHaveProperty('context'); - expect(res.body.payload_to_castle.user.id).toBe(VALID_USER_ID); - expect(res.body.result.policy.action).toBe('allow'); }); - test('valid username + bad password goes to filter', async () => { + test('a wrong password filters the attempt then filters the failure', async () => { const castle = stubbedCastle(); const res = await request(buildApp(castle)).post('/evaluate_login').send({ email: VALID_USERNAME, @@ -108,13 +115,16 @@ describe('POST /evaluate_login', () => { request_token: 'tok', }); - expect(res.body.api_endpoint).toBe('filter'); - expect(res.body.castle_status).toBe('$failed'); - expect(castle.filter).toHaveBeenCalledTimes(1); + const [attempt, outcome] = res.body.steps; + expect(attempt.castle_status).toBe('$attempted'); + expect(outcome.api_endpoint).toBe('filter'); + expect(outcome.castle_status).toBe('$failed'); + expect(outcome.payload_to_castle.matching_user_id).toBe(VALID_USER_ID); + expect(castle.filter).toHaveBeenCalledTimes(2); expect(castle.risk).not.toHaveBeenCalled(); }); - test('invalid username goes to filter with a null user id', async () => { + test('an unknown user filters the failure without a matching_user_id', async () => { const castle = stubbedCastle(); const res = await request(buildApp(castle)).post('/evaluate_login').send({ email: 'someone-else@example.com', @@ -122,8 +132,11 @@ describe('POST /evaluate_login', () => { request_token: 'tok', }); - expect(res.body.api_endpoint).toBe('filter'); - expect(res.body.payload_to_castle.user.id).toBeNull(); + const outcome = res.body.steps[1]; + expect(outcome.api_endpoint).toBe('filter'); + expect(outcome.castle_status).toBe('$failed'); + expect(outcome.payload_to_castle).not.toHaveProperty('matching_user_id'); + expect(outcome.payload_to_castle.params.email).toBe('someone-else@example.com'); }); test('surfaces API errors without crashing', async () => { @@ -137,41 +150,42 @@ describe('POST /evaluate_login', () => { }); expect(res.status).toBe(200); - expect(res.body.result.error).toMatch(/401/); + expect(res.body.steps[1].result.error).toMatch(/401/); }); }); describe('POST /evaluate_signup', () => { - test('a new email is risk-assessed as $registration', async () => { + test('a new email is filtered as $registration / $attempted', 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.api_endpoint).toBe('filter'); expect(res.body.castle_type).toBe('$registration'); - expect(res.body.castle_status).toBe('$succeeded'); - expect(castle.risk).toHaveBeenCalledTimes(1); + expect(res.body.castle_status).toBe('$attempted'); + expect(res.body.payload_to_castle.params.email).toBe('lois.lane@dailyplanet.com'); + expect(res.body.payload_to_castle).not.toHaveProperty('matching_user_id'); expect(res.body.payload_to_castle).not.toHaveProperty('context'); + expect(castle.filter).toHaveBeenCalledTimes(1); + expect(castle.risk).not.toHaveBeenCalled(); }); - test('an already-registered email goes to filter as $failed', async () => { + test('an already-registered email is filtered as $failed with matching_user_id', 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(res.body.payload_to_castle.matching_user_id).toBe(VALID_USER_ID); expect(castle.filter).toHaveBeenCalledTimes(1); - expect(castle.risk).not.toHaveBeenCalled(); }); }); diff --git a/test/frontend.test.js b/test/frontend.test.js index ef167b1..dfb03a4 100644 --- a/test/frontend.test.js +++ b/test/frontend.test.js @@ -80,3 +80,34 @@ describe('verdict banner', () => { expect(document.querySelector('.verdict')).toBeNull(); }); }); + +describe('step sequence renderer', () => { + test('renders one endpoint badge and verdict per step', () => { + window.renderCastleSteps([ + { + api_endpoint: 'filter', + payload_to_castle: { type: '$login', status: '$attempted' }, + result: { policy: { action: 'allow' }, risk: 0.1 }, + }, + { + api_endpoint: 'risk', + payload_to_castle: { type: '$login', status: '$succeeded' }, + result: { policy: { action: 'deny' }, risk: 0.9 }, + }, + ]); + + const badges = [...document.querySelectorAll('.badge.endpoint')].map( + (b) => b.textContent + ); + expect(badges).toEqual(['/filter', '/risk']); + + const verdicts = [...document.querySelectorAll('.verdict-action')].map( + (v) => v.textContent + ); + expect(verdicts).toEqual(['allow', 'deny']); + + expect( + document.getElementById('results-card').classList.contains('hidden') + ).toBe(false); + }); +}); diff --git a/test/sdk-integration.test.js b/test/sdk-integration.test.js index 8ca323a..b6a47f4 100644 --- a/test/sdk-integration.test.js +++ b/test/sdk-integration.test.js @@ -86,8 +86,10 @@ function makeCastle(overrideFetch, extra = {}) { const flush = () => new Promise((resolve) => setImmediate(resolve)); describe('risk / filter request building', () => { - test('a successful login issues a signed POST /v1/risk with the full payload', async () => { + test('a successful login filters the attempt then issues a signed POST /v1/risk', async () => { const fetch = recordingFetch({ + 'POST /v1/filter': () => + httpResponse(200, { policy: { action: 'allow' }, risk: 0.1 }), 'POST /v1/risk': () => httpResponse(200, { policy: { action: 'allow' }, @@ -104,30 +106,42 @@ describe('risk / filter request building', () => { // app-level mapping expect(res.status).toBe(200); - expect(res.body.api_endpoint).toBe('risk'); - expect(res.body.result.policy.action).toBe('allow'); - expect(res.body.result.risk).toBe(0.1); + expect(res.body.steps).toHaveLength(2); + expect(res.body.steps[1].api_endpoint).toBe('risk'); + expect(res.body.steps[1].result.policy.action).toBe('allow'); + expect(res.body.steps[1].result.risk).toBe(0.1); - // SDK-level request - expect(fetch.calls).toHaveLength(1); - const call = fetch.calls[0]; - expect(call.method).toBe('POST'); - expect(call.pathname).toBe('/v1/risk'); - expect(call.headers.Authorization).toBe(EXPECTED_AUTH); - expect(call.headers['Content-Type']).toBe('application/json'); - expect(call.body).toMatchObject({ + // SDK-level requests: Filter the attempt, then Risk the success — reusing + // the same request token. + expect(fetch.calls.map((c) => `${c.method} ${c.pathname}`)).toEqual([ + 'POST /v1/filter', + 'POST /v1/risk', + ]); + + const attempt = fetch.calls[0]; + expect(attempt.body).toMatchObject({ + type: '$login', + status: '$attempted', + request_token: 'tok_123', + params: { email: VALID_USERNAME }, + }); + + const riskCall = fetch.calls[1]; + expect(riskCall.headers.Authorization).toBe(EXPECTED_AUTH); + expect(riskCall.headers['Content-Type']).toBe('application/json'); + expect(riskCall.body).toMatchObject({ type: '$login', status: '$succeeded', request_token: 'tok_123', user: { id: VALID_USER_ID, email: VALID_USERNAME }, }); - expect(call.body.sent_at).toBeDefined(); - expect(call.body.context).toBeInstanceOf(Object); + expect(riskCall.body.sent_at).toBeDefined(); + expect(riskCall.body.context).toBeInstanceOf(Object); // the client IP from X-Forwarded-For is forwarded in the context - expect(call.body.context.ip).toBe('203.0.113.7'); + expect(riskCall.body.context.ip).toBe('203.0.113.7'); }); - test('a failed login is routed to POST /v1/filter', async () => { + test('a failed login filters the attempt and the failure on POST /v1/filter', async () => { const fetch = recordingFetch({ 'POST /v1/filter': () => httpResponse(200, { policy: { action: 'deny' }, risk: 0.97 }), @@ -137,28 +151,33 @@ describe('risk / filter request building', () => { .post('/evaluate_login') .send({ email: VALID_USERNAME, password: INVALID_PASSWORD, request_token: 'tok' }); - expect(res.body.api_endpoint).toBe('filter'); - expect(res.body.result.policy.action).toBe('deny'); - expect(fetch.calls[0].pathname).toBe('/v1/filter'); - expect(fetch.calls[0].body).toMatchObject({ type: '$login', status: '$failed' }); + expect(res.body.steps[1].api_endpoint).toBe('filter'); + expect(res.body.steps[1].result.policy.action).toBe('deny'); + expect(fetch.calls.map((c) => c.pathname)).toEqual(['/v1/filter', '/v1/filter']); + expect(fetch.calls[0].body).toMatchObject({ type: '$login', status: '$attempted' }); + expect(fetch.calls[1].body).toMatchObject({ + type: '$login', + status: '$failed', + matching_user_id: VALID_USER_ID, + }); }); - test('a new registration POSTs $registration to /v1/risk', async () => { + test('a new registration POSTs $registration / $attempted to /v1/filter', async () => { const fetch = recordingFetch({ - 'POST /v1/risk': () => httpResponse(200, { policy: { action: 'allow' }, risk: 0.2 }), + 'POST /v1/filter': () => 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(res.body.api_endpoint).toBe('filter'); + expect(fetch.calls[0].pathname).toBe('/v1/filter'); expect(fetch.calls[0].body).toMatchObject({ type: '$registration', - status: '$succeeded', + status: '$attempted', request_token: 'tok', - user: { email: 'lois.lane@dailyplanet.com', name: 'Lois Lane' }, + params: { email: 'lois.lane@dailyplanet.com' }, }); }); @@ -215,7 +234,7 @@ describe('error mapping', () => { .send({ email: VALID_USERNAME, password: VALID_PASSWORD, request_token: 'tok' }); expect(res.status).toBe(200); - expect(res.body.result.error).toMatch(/401/); + expect(res.body.steps[1].result.error).toMatch(/401/); }); }); @@ -230,10 +249,10 @@ describe('failover', () => { .post('/evaluate_login') .send({ email: VALID_USERNAME, password: VALID_PASSWORD, request_token: 'tok' }); - expect(res.body.api_endpoint).toBe('risk'); - expect(res.body.result.failover).toBe(true); - expect(res.body.result.failover_reason).toBe('timeout'); - expect(res.body.result.policy.action).toBe('deny'); + expect(res.body.steps[1].api_endpoint).toBe('risk'); + expect(res.body.steps[1].result.failover).toBe(true); + expect(res.body.steps[1].result.failover_reason).toBe('timeout'); + expect(res.body.steps[1].result.policy.action).toBe('deny'); }); test('a 5xx returns a failover verdict (server error)', async () => { diff --git a/views/login.pug b/views/login.pug index ba114a3..17fe07e 100644 --- a/views/login.pug +++ b/views/login.pug @@ -23,8 +23,16 @@ block ui a(href="/signup") Create an account block desc - p A login attempt has three common outcomes, and each maps to a different Castle endpoint: + p A login reuses one request token across a two-step sequence: ol.list-decimal.pl-5.space-y-1 + li + | the attempt is always filtered first → + code $login / $attempted + | sent to + code /filter + | (anonymous, so the email goes in + code params + | ). li strong valid username + valid password | → @@ -33,19 +41,12 @@ block desc code /risk | ; act on the verdict (allow, challenge, deny). li - strong valid username + invalid password + strong wrong password / unknown user | → code $login / $failed | sent to code /filter | . - li - strong invalid username - | → - code $login / $failed - | (user id = null) sent to - code /filter - | . block scripts script. @@ -72,9 +73,12 @@ block scripts password: document.getElementById("password").value, request_token: requestToken, }).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; + renderCastleSteps(data.steps); + // Act on the outcome (the last step): a /risk allow lets the user + // continue to the React account page. + var steps = data.steps || []; + var last = steps[steps.length - 1]; + var action = last && last.result && last.result.policy && last.result.policy.action; if (action === "allow") { var results = document.getElementById("results"); var wrap = document.createElement("div"); diff --git a/views/signup.pug b/views/signup.pug index 6965f57..73a4f39 100644 --- a/views/signup.pug +++ b/views/signup.pug @@ -21,22 +21,24 @@ block ui a(href="/login") Already have an account? Log in block desc - p A registration is a sensitive action, so it is risk-assessed: + p A registration is evaluated before the account exists, so it is anonymous activity sent to + code /filter + | with the form + code params + | : 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). + code $registration / $attempted + | ; act on the verdict (allow, challenge, deny) before creating the account. li strong an email that already exists | → code $registration / $failed - | sent to - code /filter - | . + | (resolved to the existing user via + code matching_user_id + | ). block scripts script.