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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
# <BETTER_AUTH_URL>/api/auth/callback/<google|github|microsoft>
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Expand All @@ -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 <studio@developmentfinance.org>. 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.
Expand Down
7 changes: 4 additions & 3 deletions db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<user id>`); `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:<inbox digest>`,
* `sign-in-code:<digest>`); `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.
*/
Expand Down
57 changes: 51 additions & 6 deletions db/test/limit-counter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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 });
}
},
);
});
4 changes: 2 additions & 2 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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).

Expand Down
11 changes: 10 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
19 changes: 11 additions & 8 deletions docs/feedback.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading