diff --git a/.env.example b/.env.example index 4ac96ef..ff1c1e0 100644 --- a/.env.example +++ b/.env.example @@ -45,10 +45,9 @@ DATABASE_URL=postgresql://website_runtime:website_runtime_dev_password@localhost BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 -# Sign-in providers (Better Auth social sign-in — the only way to get an -# account; there are no password accounts). Each pair is optional: a -# provider without keys simply isn't offered on /login. Callback URL to -# register with every provider: +# Sign-in providers (Better Auth social sign-in). There are no password +# accounts. Each pair is optional: a provider without keys isn't offered on +# /login. Callback URL to register with every provider: # /api/auth/callback/ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= @@ -57,20 +56,47 @@ GITHUB_CLIENT_SECRET= MICROSOFT_CLIENT_ID= MICROSOFT_CLIENT_SECRET= +# Email sign-in: /login emails a six-digit code (src/lib/auth/emailSignIn.ts). +# On only when these three and a mail transport (below) are all set, so no +# code is sent without the Turnstile bot check. AUTH_EMAIL_FROM is the From header, +# e.g. DFO Studio . Turnstile keys come from +# the Cloudflare dashboard; locally, Cloudflare's always-pass test keys work: +# site 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA. +AUTH_EMAIL_FROM= +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + # Feedback form (POST /api/feedback, docs/feedback.md). Submissions are # stored via DATABASE_URL above; each sink below is optional and off when # unset. The Slack webhook posts each submission to a channel (deployed: # mounted from Secret Manager by dfo-infra; values that aren't a -# hooks.slack.com URL mean off). The SMTP block emails submissions to -# FEEDBACK_TO; unused until an email decision exists. +# hooks.slack.com URL mean off). Submissions are emailed to FEEDBACK_TO +# through the mail transport below. FEEDBACK_SLACK_WEBHOOK_URL= +FEEDBACK_FROM= +FEEDBACK_TO= + +# Mail transport, for sign-in codes and feedback (src/lib/mail/settings.ts). +# Deployed environments send through the Amazon SES v2 API. AWS STS trades +# the Cloud Run service account's Google ID token, minted for the audience +# SES_TOKEN_AUDIENCE, for temporary credentials for the IAM role +# SES_ROLE_ARN, whose trust policy names that service account and audience. +# No AWS key is stored. SES_REGION is the SES account's region, e.g. +# us-east-1. Setting some of the three but not all turns mail off. The role +# sends only as the address its policy names, studio@developmentfinance.org, +# so AUTH_EMAIL_FROM and FEEDBACK_FROM use that address in the same case. A +# display name in front of it is fine. +SES_ROLE_ARN= +SES_REGION= +SES_TOKEN_AUDIENCE= +# Local work uses SMTP against a mail catcher (e.g. Mailpit), used only when +# the SES settings are unset. The connection must use TLS 1.2 or later, over +# STARTTLS (port 587) unless SMTP_SECURE=true. SMTP_HOST= SMTP_PORT= SMTP_SECURE= SMTP_USER= SMTP_PASS= -FEEDBACK_FROM= -FEEDBACK_TO= # Sentry (errors, browser + server; docs/analytics.md). Off without a DSN. # Usage events need no key — they land in the `event` table. diff --git a/db/schema.ts b/db/schema.ts index 27530c1..e6ff7e9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -223,9 +223,10 @@ export type EventRow = typeof event.$inferSelect; /** * One counter per limiter key (src/lib/proxy/limitCounter): the per-address - * limits on /api and /mcp and the Studio's daily prompt allowances. `key` - * names the allowance and the caller (`api:203.0.113.7`, - * `studio-user:`); `count` is the hits in the window that started + * limits on /api and /mcp, the Studio's daily prompt allowances and the + * hourly caps on sign-in codes sent. `key` names the allowance and the + * caller (`api:203.0.113.7`, `studio-user:`, + * `sign-in-code:`); `count` is the hits in the window that started * at `windowStart`. The next hit after the window ends starts the row over, * and rows idle for two days are deleted. */ diff --git a/db/test/limit-counter.test.ts b/db/test/limit-counter.test.ts index c0955bd..fb85d0d 100644 --- a/db/test/limit-counter.test.ts +++ b/db/test/limit-counter.test.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import assert from 'node:assert/strict'; import test from 'node:test'; import { drizzle } from 'drizzle-orm/node-postgres'; +import { CODE_LIMITS, codeLimitKeys, takeCodeAllowance } from '../../src/lib/auth/codeLimit.ts'; import { countHit, pruneCounters, @@ -19,7 +20,8 @@ const DAY_S = 86_400; /** * The shared limiter (src/lib/proxy/limitCounter) and the Studio's prompt - * allowances on top of it (src/lib/studio/spendPrompt), run as the runtime + * allowances and sign-in code caps on top of it (src/lib/studio/spendPrompt, + * src/lib/auth/codeLimit), run as the runtime * role on a migrated database, so migration 0017's table and the default * grants are exercised too. */ @@ -249,19 +251,62 @@ test('limit counters and Studio prompt allowances', async (t) => { assert.equal(rows[0]?.count, GUEST_PROMPTS); }); - await t.test('a signed-in user past the daily allowance is refused', async () => { + await t.test('a signed-in inbox past the daily allowance is refused', async () => { // Signing in after the guest wall opens the user's own allowance. - assert.equal((await spendPrompt(db, { userId: 'user-a' })).refusal, null); + assert.equal((await spendPrompt(db, { email: 'ana.silva@gmail.com' })).refusal, null); + // An email code makes an account per spelling of one address; the + // spellings draw on one allowance. for (let i = 1; i < USER_DAILY_PROMPTS; i++) { - await spendPrompt(db, { userId: 'user-a' }); + await spendPrompt(db, { + email: i % 2 ? 'AnaSilva+dfo@gmail.com' : 'ana.silva@googlemail.com', + }); } - const refusal = (await spendPrompt(db, { userId: 'user-a' })).refusal; + const refusal = (await spendPrompt(db, { email: 'a.n.a.silva@gmail.com' })).refusal; assert.equal(refusal?.error, 'resource_exhausted'); assert.equal(refusal?.retry, 'retry_after_delay'); assert.ok(refusal!.retry_after_seconds! > 0 && refusal!.retry_after_seconds! <= DAY_S); - assert.equal((await spendPrompt(db, { userId: 'user-b' })).refusal, null); + assert.equal((await spendPrompt(db, { email: 'ana.silva2@gmail.com' })).refusal, null); + + const { rows } = await admin.query<{ n: number }>( + `select count(*)::int as n from limit_counter where key like 'studio-user:%@%'`, + ); + assert.equal(rows[0]?.n, 0, 'a signed-in key holds no email address'); }); + + await t.test( + 'a sign-in code past a cap is refused, and the refusal charges no key', + async () => { + const send = (email: string, ip: string) => + takeCodeAllowance(db, codeLimitKeys('secret', email, ip)); + + for (let i = 0; i < CODE_LIMITS.address; i++) { + assert.deepEqual(await send('ana@one.org', '203.0.113.40'), { ok: true }); + } + + const refused = await send('ana@one.org', '203.0.113.40'); + + assert.equal(refused.ok, false); + assert.ok( + !refused.ok && + refused.retryAfterS > 0 && + refused.retryAfterS <= CODE_LIMITS.windowS, + ); + + // Use up one network's cap on other inboxes, then aim it at bea. + for (let i = 0; i < CODE_LIMITS.network; i++) { + await send(`user${i}@one.org`, '203.0.113.41'); + } + for (let i = 0; i < CODE_LIMITS.address + 1; i++) { + assert.equal((await send('bea@one.org', '203.0.113.41')).ok, false); + } + + // Those refusals left bea's inbox uncharged. + for (let i = 0; i < CODE_LIMITS.address; i++) { + assert.deepEqual(await send('bea@one.org', '203.0.113.42'), { ok: true }); + } + }, + ); }); diff --git a/docs/analytics.md b/docs/analytics.md index b949aa1..e21996d 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -21,7 +21,7 @@ The browser stores `visitor_id` only after the visitor accepts "DFO usage statis | `studio.prompt` | a prompt sent in the Studio | `turn`, `intent` (`new` / `follow_up` / `edit`), `length`, `guest` | | `studio.card` | a card the harness put on the canvas | `kind`, `turn`, `source`, `indicators` (DCIDs), `years`, `unit` (in words with its magnitude and price basis, `US Dollar, millions, constant 2023 prices`; a composed unit as its numerator and denominator either side of "per", each part's magnitude and price basis in brackets beside it, `US Dollar per Total population` or `Euro (constant 2020 prices) per Total population`; a percentage as `Percent`; null when the card states none), `guest` | | `studio.session_end` | New Chat, or the page is left | `turns`, `cards`, `pins`, `guest` | -| `auth.signin` | a provider callback ended in a session | `provider` | +| `auth.signin` | a provider callback or an email code ended in a session | `provider` (`email` for a code) | | `guest.wall_hit` | a guest used up the free prompts | `prompts` | | `feedback.submit` | a feedback submission | `kind` (`chat` / `widget`) | | `share.create` | a share link stored | `shareId`, `kind` (`conversation` / `canvas` / `card`, or `search` for a search result), `mode` (`live` / `snapshot`). A search share joins its `search.action` through `shareId` | @@ -104,7 +104,7 @@ The diagnostic event carries `RSC` and `Next-Router-Prefetch` headers before Nex ## Studio prompt allowance -A Studio prompt is one turn (`POST /api/studio/turn`). The proxy atomically reserves one from the caller's daily allowance before it forwards the turn (`src/lib/studio/spendPrompt.ts`). Refused requests consume no allowance. A guest gets 5 a day per network address, an IPv6 address counted by its /64, so guests behind one shared address share them. A signed-in user gets 75 a day, counted by user id. Both reset at 00:00 UTC. The numbers live in `src/lib/studio/promptAllowance.ts` and the counts in the `limit_counter` table. A turn past its allowance gets a 429 `resource_exhausted`, whose detail the chat shows as the reply. Allowance and rate-limit queries use a separate two-connection pool with a 400 ms database statement limit, a 500 ms client query limit, and a 2 s connection limit, so they cannot occupy the pool used for sessions and history. An allowance that cannot be read gets a 503. A turn the harness answers with anything but success gives its reservation back in its original counter window, since no model ran. The proxy forwards only the four routes the browser calls (`turn`, `interrupt`, `cancel`, `composition`) and answers 404 to any other path, so no encoding of a path reaches the turn route without spending a prompt. +A Studio prompt is one turn (`POST /api/studio/turn`). The proxy atomically reserves one from the caller's daily allowance before it forwards the turn (`src/lib/studio/spendPrompt.ts`). Refused requests consume no allowance. A guest gets 5 a day per network address, an IPv6 address counted by its /64, so guests behind one shared address share them. A signed-in user gets 75 a day, counted per inbox: the address lower-cased, without a `+tag` and, for Gmail, without dots (`inboxOf` in `src/lib/auth/inbox.ts`). An email code makes a separate account for each spelling of one address, and those accounts share the 75. Both reset at 00:00 UTC. The numbers live in `src/lib/studio/promptAllowance.ts` and the counts in the `limit_counter` table. A turn past its allowance gets a 429 `resource_exhausted`, whose detail the chat shows as the reply. Allowance and rate-limit queries use a separate two-connection pool with a 400 ms database statement limit, a 500 ms client query limit, and a 2 s connection limit, so they cannot occupy the pool used for sessions and history. An allowance that cannot be read gets a 503. A turn the harness answers with anything but success gives its reservation back in its original counter window, since no model ran. The proxy forwards only the four routes the browser calls (`turn`, `interrupt`, `cancel`, `composition`) and answers 404 to any other path, so no encoding of a path reaches the turn route without spending a prompt. The browser also counts a guest's prompts in localStorage and replaces the prompt with a sign-in wall at 5. Clearing it brings the prompt back but not the allowance. Guests get no history, saving or sharing (all need an account). diff --git a/docs/development.md b/docs/development.md index 4ab817c..7a44130 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,16 @@ Copy `.env.example` to `.env.local`. The static pages run without it. The data search and graph pages need `SEARCH_API_BASE`, which the sample points at the proxy in [Reach the dev APIs](#reach-the-dev-apis); the Studio needs a harness at `STUDIO_API_BASE` and a `STUDIO_TOKEN`; accounts need `DATABASE_URL` and -`BETTER_AUTH_SECRET`. To open the dev server from a +`BETTER_AUTH_SECRET`. Email sign-in also needs a mail transport, +`AUTH_EMAIL_FROM` and the two Turnstile keys (`.env.example` has +Cloudflare's test keys for local use). Deployed, mail goes through the +Amazon SES API with `SES_ROLE_ARN`, `SES_REGION` and `SES_TOKEN_AUDIENCE`, +on temporary AWS credentials traded for the service account's Google ID +token minted for that audience. The role sends only as the address its +policy names, `studio@developmentfinance.org`, so `AUTH_EMAIL_FROM` and +`FEEDBACK_FROM` use that address in the same case, with or without a +display name. Locally, `SMTP_HOST` and the other SMTP settings point mail +at a mail catcher. To open the dev server from a phone/tablet on the same network, put the machine's LAN IP in `DEV_ORIGINS`. diff --git a/docs/feedback.md b/docs/feedback.md index 99eb552..739309c 100644 --- a/docs/feedback.md +++ b/docs/feedback.md @@ -1,6 +1,6 @@ # Feedback backend -`POST /api/feedback` takes a feedback submission and fans it out to up to three sinks, each active only when configured: the website tenant database (permanent record), a Slack channel via incoming webhook (how the team sees submissions day to day), and email over SMTP (unused until an email decision exists). Any subset is a valid deployment phase; with none configured the endpoint answers 503 so a form wired up too early fails loudly instead of dropping submissions. +`POST /api/feedback` takes a feedback submission and fans it out to up to three sinks, each active only when configured: the website tenant database (permanent record), a Slack channel via incoming webhook (how the team sees submissions day to day), and email through the site's mail transport, the Amazon SES API when deployed and SMTP locally (unused until an email decision exists). Any subset is a valid deployment phase; with none configured the endpoint answers 503 so a form wired up too early fails loudly instead of dropping submissions. ## Request @@ -29,13 +29,16 @@ A submission with a filled honeypot gets a fake 200 so bots learn nothing. A sin ## Configuration -| Variable | Sink | Meaning | -|---|---|---| -| `DATABASE_URL` / `WEBSITE_DB_*` | storage | The shared pool from `src/lib/db`; nothing feedback-specific, storage is on wherever the app has a database | -| `FEEDBACK_SLACK_WEBHOOK_URL` | slack | Slack incoming webhook; deployed it is mounted from Secret Manager by dfo-infra, whose placeholder value `disabled` (or anything that isn't a URL) means off | -| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | email | SMTP endpoint; `SMTP_HOST` unset turns email off | -| `SMTP_USER` / `SMTP_PASS` | email | optional SMTP auth | -| `FEEDBACK_FROM` / `FEEDBACK_TO` | email | sender identity and destination inbox | +| Variable | Sink | Meaning | +| ---------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` / `WEBSITE_DB_*` | storage | The shared pool from `src/lib/db`; nothing feedback-specific, storage is on wherever the app has a database | +| `FEEDBACK_SLACK_WEBHOOK_URL` | slack | Slack incoming webhook; deployed it is mounted from Secret Manager by dfo-infra, whose placeholder value `disabled` (or anything that isn't a URL) means off | +| `SES_ROLE_ARN` / `SES_REGION` / `SES_TOKEN_AUDIENCE` | email | Amazon SES API, shared with email sign-in (`src/lib/mail/ses.ts`). The IAM role is assumed with the Cloud Run service account's Google ID token for `SES_TOKEN_AUDIENCE`, so no AWS key is stored. Takes precedence over SMTP; some without the rest turns email off | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | email | SMTP endpoint for local work against a mail catcher (`src/lib/mail/smtp.ts`). The connection must use TLS 1.2 or later. With neither transport set, email is off | +| `SMTP_USER` / `SMTP_PASS` | email | optional SMTP auth | +| `FEEDBACK_FROM` / `FEEDBACK_TO` | email | sender identity and destination inbox | + +Over SES the role sends only as the address its policy names, `studio@developmentfinance.org`, so `FEEDBACK_FROM` uses that address in the same case, with or without a display name. SES refuses a send from any other address. Rotating the Slack webhook (leak, channel change) is a Secret Manager version plus a redeploy, no code change; see the dfo-infra website stack README. diff --git a/package.json b/package.json index dc1b5ed..40b8e83 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint:css": "stylelint \"src/**/*.scss\"", "lint:css:fix": "stylelint \"src/**/*.scss\" --fix", "typecheck": "tsc --noEmit", - "test": "node --test db/test/*.test.ts src/lib/search/test/*.test.ts src/lib/studio/test/*.test.ts src/lib/chat/test/*.test.ts src/lib/proxy/test/*.test.ts src/lib/sources/test/*.test.ts src/lib/test/*.test.ts src/lib/docs/test/*.test.ts src/lib/graph/test/*.test.ts", + "test": "node --test db/test/*.test.ts src/lib/search/test/*.test.ts src/lib/studio/test/*.test.ts src/lib/chat/test/*.test.ts src/lib/proxy/test/*.test.ts src/lib/auth/test/*.test.ts src/lib/mail/test/*.test.ts src/lib/sources/test/*.test.ts src/lib/test/*.test.ts src/lib/docs/test/*.test.ts src/lib/graph/test/*.test.ts", "graph:types": "node scripts/graph-wire.mts types", "graph:schema": "node scripts/graph-wire.mts schema", "format": "prettier --write \"src/**/*.{ts,tsx,js,json,scss}\"", @@ -24,6 +24,8 @@ "build:migrate": "esbuild db/migrate.ts --bundle --platform=node --target=node24 --format=cjs --outfile=dist/migrate.cjs --external:pg-native" }, "dependencies": { + "@aws-sdk/client-sesv2": "3.1150.0", + "@aws-sdk/credential-providers": "3.1150.0", "@fontsource/dm-mono": "^5.3.0", "@fontsource/outfit": "^5.3.0", "@google-cloud/cloud-sql-connector": "^1.12.0", @@ -34,6 +36,7 @@ "better-auth": "^1.6.29", "chart.js": "^4.5.1", "classnames": "^2.5.1", + "disposable-email-domains-js": "^1.26.0", "drizzle-orm": "^0.45.2", "html-to-image": "^1.11.13", "lowlight": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad4ce0a..c673067 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@aws-sdk/client-sesv2': + specifier: 3.1150.0 + version: 3.1150.0 + '@aws-sdk/credential-providers': + specifier: 3.1150.0 + version: 3.1150.0 '@fontsource/dm-mono': specifier: ^5.3.0 version: 5.3.0 @@ -38,6 +44,9 @@ importers: classnames: specifier: ^2.5.1 version: 2.5.1 + disposable-email-domains-js: + specifier: ^1.26.0 + version: 1.26.0 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.5)(kysely@0.29.5)(pg@8.23.0) @@ -147,6 +156,78 @@ packages: '@antfu/install-pkg@2.1.0': resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + '@aws-sdk/client-sesv2@3.1150.0': + resolution: {integrity: sha512-19N2D/A53kgqDAWNx+maZ6m7Ddq/43S7jyOYzI7QYKssGwsvq1Mw3A5dljqretzeo2djd1iCx/htfRVJil4eIQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.978.4': + resolution: {integrity: sha512-JvGp6y9sPX8FSs2K/EmFMZcUfdkHxqGE45+oxwGC75OIh50j2e4cbx7zcR3BwNs2DSGqJdBja5h6Vy89iIDmRQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-cognito-identity@3.972.74': + resolution: {integrity: sha512-IYfhaLlWr5Y1gyhm87aChm4SkxNAiRtNuKyUyNUqQ9igyfdlp2nPeycL0Cdi88oovBqVoHim1b9NaiEDZ3DfCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.75': + resolution: {integrity: sha512-nc8Epg98PcYoKNx0VFlwtmZSCP+tuJ1FLWqpslET/gp4h/WYuULSJqtxppM6ywKKDdzNJPvLD7ceRfmxm3Qdxw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.77': + resolution: {integrity: sha512-KkYFdIQwuw9IOHfEzK78slxmVWuhcm1w6YgE3V3WVxAeRRj9q5J9jlwzpY/oszWxd4SD96FTFkDdRGBdMFF2Mw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.20': + resolution: {integrity: sha512-OSl2R/0zLkoeKEIy6oP2tu/bsSdY+DGgro2eOffMD2CcP8POv7rS14FY5Jzc5bjnqB1G9wv/aM1akHC2mdlhZw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.82': + resolution: {integrity: sha512-8jyDPtf1WPxeQ/5xlNpyxAD6P7YKwkYeNk8B2vFEDwkPWDvb7c3tQJG5/jbNWbF2yTMIH9tDTueBkdfovYIAPw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.87': + resolution: {integrity: sha512-TKj2C0x4IMPSSOAlmeryX8fCPvvPS2pFJz2ikV9IGqyh6Ehsg4leMalCTVfV2rvMTrlGAe7X7nK0HZ3P0KZR9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.75': + resolution: {integrity: sha512-ZNm8T/HJNhpS8MikDVAsym8QyUSUuOUGiCkmI3TTya7G6xhOgfRLd2iK91HhQQyvRM36eo6Xm845hHVyfToA2w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.19': + resolution: {integrity: sha512-qHGDAGesyWtRw3BapyFkYSUzVNrC1CWPS6+S2lf/MLMEkWB2gR3Bl6imVHy1nhImXKS+gcuUDzbu39BnsHJc1Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.81': + resolution: {integrity: sha512-nHkgkMIC6NHgqfI6UAQq5cCS4bUkNxkT+r9fXCq/X4IKfX/DHGZ8UM3T4b78OXpWK5OlzEzN4kxwyax0W/MUCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-providers@3.1150.0': + resolution: {integrity: sha512-wqJ2MjeDMTQhwuelUjc60mpKvDGWtOMjVJ1ZNxD7AapNIBFy5RVYPSys54RYeD8Gm3SwIKS1TwF934lXQ/CIeA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.49': + resolution: {integrity: sha512-zjqIqzTYLRKhT7TPJDUreK9vG9PjhmnfLCaTaqArkRT7j+Ogl0tuXB7MpSd8rlRfRT+xTOeyNFWaOfyNcf51hg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.48': + resolution: {integrity: sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1153.0': + resolution: {integrity: sha512-6bKJ2OfLboxhzeJ3hBDeB8L6emCYf9DfdrjBAharLrY42J1RV/6jA6G8jvfNghzID8Yb40BdSY7jQMtWx9Ot0Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.6': + resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.41': + resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -2472,6 +2553,30 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/core@3.36.0': + resolution: {integrity: sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3168,6 +3273,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -3610,6 +3718,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + disposable-email-domains-js@1.26.0: + resolution: {integrity: sha512-pSqALZAiuw6HDC1HqhhtvShRnniUNObQ6kzwa5N280XbyxxCPBZnQQ7fO6Uk/CNTi4cAaKE1UZl3Mc30Zfj4UA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -6067,6 +6178,179 @@ snapshots: package-manager-detector: 1.9.0 tinyexec: 1.3.1 + '@aws-sdk/client-sesv2@3.1150.0': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/credential-provider-node': 3.972.87 + '@aws-sdk/signature-v4-multi-region': 3.996.48 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.978.4': + dependencies: + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.36.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-cognito-identity@3.972.74': + dependencies: + '@aws-sdk/nested-clients': 3.997.49 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.75': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.20': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/credential-provider-env': 3.972.75 + '@aws-sdk/credential-provider-http': 3.972.77 + '@aws-sdk/credential-provider-login': 3.972.82 + '@aws-sdk/credential-provider-process': 3.972.75 + '@aws-sdk/credential-provider-sso': 3.973.19 + '@aws-sdk/credential-provider-web-identity': 3.972.81 + '@aws-sdk/nested-clients': 3.997.49 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.82': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/nested-clients': 3.997.49 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.87': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.75 + '@aws-sdk/credential-provider-http': 3.972.77 + '@aws-sdk/credential-provider-ini': 3.973.20 + '@aws-sdk/credential-provider-process': 3.972.75 + '@aws-sdk/credential-provider-sso': 3.973.19 + '@aws-sdk/credential-provider-web-identity': 3.972.81 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.75': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.19': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/nested-clients': 3.997.49 + '@aws-sdk/token-providers': 3.1153.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.81': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/nested-clients': 3.997.49 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-providers@3.1150.0': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/credential-provider-cognito-identity': 3.972.74 + '@aws-sdk/credential-provider-env': 3.972.75 + '@aws-sdk/credential-provider-http': 3.972.77 + '@aws-sdk/credential-provider-ini': 3.973.20 + '@aws-sdk/credential-provider-login': 3.972.82 + '@aws-sdk/credential-provider-node': 3.972.87 + '@aws-sdk/credential-provider-process': 3.972.75 + '@aws-sdk/credential-provider-sso': 3.973.19 + '@aws-sdk/credential-provider-web-identity': 3.972.81 + '@aws-sdk/nested-clients': 3.997.49 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.49': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/signature-v4-multi-region': 3.996.48 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.48': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1153.0': + dependencies: + '@aws-sdk/core': 3.978.4 + '@aws-sdk/nested-clients': 3.997.49 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.6': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.41': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -8254,6 +8538,39 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.36.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.4': + dependencies: + '@smithy/core': 3.36.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/types@4.19.0': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@stylistic/stylelint-plugin@5.3.0(stylelint@17.14.1(typescript@5.9.3))': @@ -9004,6 +9321,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -9463,6 +9782,8 @@ snapshots: dependencies: dequal: 2.0.3 + disposable-email-domains-js@1.26.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 8bb3465..8302278 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,7 +1,10 @@ import { toNextJsHandler } from 'better-auth/next-js'; import { auth } from '@/lib/auth/auth'; +import { clientIpHandlers } from '@/lib/auth/clientIp'; +import { trustedHops } from '@/lib/proxy/caller'; -// Better Auth's endpoints (sign-in, callback, session, sign-out) — all under -// /api/auth/*. -export const { GET, POST } = toNextJsHandler(auth); +// Better Auth's endpoints (sign-in, callback, session, sign-out, email +// codes), all under /api/auth/*. Each request first gets the client IP +// header Better Auth keys its limits on (see lib/auth/clientIp). +export const { GET, POST } = clientIpHandlers(toNextJsHandler(auth), trustedHops(process.env)); diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index 3254755..87a23aa 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -4,7 +4,7 @@ import { slackConfigured, sendFeedbackSlack } from '@/lib/feedback/slack'; import { storageConfigured, storeFeedback } from '@/lib/feedback/store'; import { track } from '@/lib/events/track'; -// Feedback intake (POST /api/feedback). Three independent sinks, each active only when configured: storage in the website tenant database, a Slack channel via incoming webhook, and email over SMTP. Any subset is a valid deployment phase. +// Feedback intake (POST /api/feedback). Three independent sinks, each active only when configured: storage in the website tenant database, a Slack channel via incoming webhook, and email through the site's mail transport (src/lib/mail). Any subset is a valid deployment phase. // The frontend contract is documented in docs/feedback.md. Responses mirror the brochure site's newsletter endpoint's { ok, message } shape so form code can treat them alike. export const runtime = 'nodejs'; diff --git a/src/app/api/v1/studio/[...path]/route.ts b/src/app/api/v1/studio/[...path]/route.ts index 69febb0..dce4f09 100644 --- a/src/app/api/v1/studio/[...path]/route.ts +++ b/src/app/api/v1/studio/[...path]/route.ts @@ -48,7 +48,7 @@ const proxy = async (req: NextRequest, { params }: { params: Promise<{ path: str const spend = async () => { const user = await getUser(); - const prompter: Prompter = user ? { userId: user.id } : { address: clientIp(req) }; + const prompter: Prompter = user ? { email: user.email } : { address: clientIp(req) }; return spendPrompt(counterDb, prompter); }; diff --git a/src/components/atoms/Button/Button.module.scss b/src/components/atoms/Button/Button.module.scss index a12af01..9aab427 100644 --- a/src/components/atoms/Button/Button.module.scss +++ b/src/components/atoms/Button/Button.module.scss @@ -33,7 +33,8 @@ position: relative; background-color: var(--seg-bg, transparent); border-radius: rem(4); - transition: background-color 0.25s ease, + transition: + background-color 0.25s ease, box-shadow 0.25s ease, color 0.25s ease; } @@ -47,7 +48,8 @@ overflow: hidden; background-color: var(--seg-bg, transparent); border-radius: rem(4); - transition: background-color 0.25s ease, + transition: + background-color 0.25s ease, box-shadow 0.25s ease, color 0.25s ease; @@ -193,12 +195,14 @@ // Bare text button — no fill, muted color (e.g. "Why these results?"). // With an icon (chevron) the segment shrinks to ride inline with the text. +// The colour suits dark sections; on a light surface set --plain-color +// (e.g. to --color-charcoal) on the button. .plain { gap: rem(6); .label { padding-inline: 0; - color: var(--color-media-bg); + color: var(--plain-color, var(--color-media-bg)); background-color: transparent; } @@ -206,7 +210,7 @@ align-self: center; width: rem(14); height: rem(14); - color: var(--color-media-bg); + color: var(--plain-color, var(--color-media-bg)); background-color: transparent; svg { @@ -226,7 +230,7 @@ .label, .icon { - color: var(--color-media-bg); + color: var(--plain-color, var(--color-media-bg)); background-color: transparent; } } diff --git a/src/components/atoms/Turnstile/Turnstile.tsx b/src/components/atoms/Turnstile/Turnstile.tsx new file mode 100644 index 0000000..8ea1007 --- /dev/null +++ b/src/components/atoms/Turnstile/Turnstile.tsx @@ -0,0 +1,85 @@ +'use client'; + +import Script from 'next/script'; +import { useCallback, useEffect, useRef } from 'react'; + +type TurnstileApi = { + render: (element: HTMLElement, options: Record) => string; + remove: (widgetId: string) => void; +}; + +declare global { + interface Window { + turnstile?: TurnstileApi; + } +} + +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + +type TurnstileProps = { + siteKey: string; + /** A fresh token, or null once it expires or the check fails. */ + onToken: (token: string | null) => void; + /** The script did not load (offline, blocked) or the check failed, so no token will come until it recovers. */ + onUnavailable: () => void; +}; + +/** + * Cloudflare Turnstile, the bot check in front of every email sign-in + * code. It stays invisible unless Cloudflare wants an interaction. A token + * is good for one request, so remount the widget (change its `key`) to get + * the next one. + */ +const Turnstile = ({ siteKey, onToken, onUnavailable }: TurnstileProps) => { + const container = useRef(null); + const widget = useRef(null); + const handlers = useRef({ onToken, onUnavailable }); + + useEffect(() => { + handlers.current = { onToken, onUnavailable }; + }); + + const render = useCallback(() => { + if (!container.current || !window.turnstile || widget.current) { + return; + } + + widget.current = window.turnstile.render(container.current, { + sitekey: siteKey, + appearance: 'interaction-only', + callback: (token: string) => handlers.current.onToken(token), + 'expired-callback': () => handlers.current.onToken(null), + 'error-callback': () => { + handlers.current.onToken(null); + handlers.current.onUnavailable(); + }, + }); + }, [siteKey]); + + // A remount after the script has loaded renders here. The first mount + // renders from the script's onReady. + useEffect(() => { + render(); + + return () => { + if (widget.current) { + window.turnstile?.remove(widget.current); + widget.current = null; + } + }; + }, [render]); + + return ( + <> +