Skip to content
Closed

Temp #94

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
9 changes: 7 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=codebloom
DATABASE_NAME=patchats
DATABASE_USER=postgres
DATABASE_PASSWORD=enterpasswordhere
# With the example values, this gets combined inside of the application.properties to make
# jdbc://postgresql://localhost:5432/codebloom?user=postgres&password=enterpasswordhere
# jdbc://postgresql://localhost:5432/patchats?user=postgres&password=enterpasswordhere

# SMTP — consumed by spring.mail.* in non-dev profiles (the dev profile logs instead of sending)
SMTP_HOST=smtp.example.com
Expand All @@ -16,3 +16,8 @@ SMTP_PASSWORD=enterpasswordhere
# The verified From sender (a real, monitored mailbox on a domain you control)
EMAIL_FROM=coffeechats@patinanetwork.org
EMAIL_FROM_NAME=PatChats

# Auth — public origin of the SPA; magic links point at $APP_BASE_URL/auth/verify?token=...
APP_BASE_URL=http://localhost:5173
# Set to false only when serving over plain HTTP (the dev profile already does this)
AUTH_COOKIE_SECURE=true
53 changes: 28 additions & 25 deletions docs/auth-feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ email, receives a single-use link, and clicking it establishes a server-side ses
httpOnly cookie.

**Form-first membership.** The sign-up form is the only way a member row is created; magic links
purely sign in **existing** members. Requesting a link never reveals whether an account exists — the
response is always the same generic 200, but for unregistered emails the backend silently sends
nothing (logged at info level). Wiring the sign-up form submission to a real create-member endpoint
is a separate ticket; until it lands, a login-capable member can only be created with a manual DB
insert (see the walkthrough below).
purely sign in **existing** members. Requesting a link for an email with no member row therefore
**fails with 404**, and the login page turns that into a dead-end panel offering the two ways
forward: try another address, or go sign up.

## The shape

Expand All @@ -22,8 +20,7 @@ src/main/java/org/patinanetwork/patchats/auth/
RequestLinkRateLimiter.java Bucket4j: 3/email + 10/IP per 15 min, in-memory buckets
AuthProperties.java @ConfigurationProperties("app.auth") → base-url, cookie-secure, magic-link-ttl
repo/
MagicLinkTokenRepository.java JdbcClient; atomic UPDATE..RETURNING consume
MemberAccountRepository.java auth's read-only view of members (findByEmail, findById)
MagicLinkTokenRepo.java JdbcClient; atomic UPDATE..RETURNING consume
security/
SecurityConfig.java filter chains, cookie serializer, CSRF rationale (read its javadoc)
AuthenticatedMember.java Serializable session principal (memberId + email)
Expand All @@ -40,12 +37,15 @@ js/src/features/auth/
1. `POST /api/auth/request-link {email}` — normalizes the email, then rate-limits **visibly**: an
exhausted budget (3/email + 10/IP per 15 min) returns HTTP 429 with a friendly message, for
**all** emails alike — the limiter runs before the member-existence check, so the 429 is
registration-blind and legitimate users know to stop retrying. Unregistered emails are skipped
*silently* (same generic 200 as a real send); that silence is the enumeration guard. For a
registered member it deletes outstanding tokens for that email, stores a **SHA-256 digest** of a
registration-blind and legitimate users know to stop retrying. Keep that ordering: it is also
what throttles probing. An email with no member row
then fails with HTTP 404 (`UnregisteredEmailException`). For a
registered member it stores a **SHA-256 digest** of a
fresh 256-bit token (raw is never persisted), and emails
`<app.auth.base-url>/auth/verify?token=<raw>`. Links expire after 15 minutes
(`app.auth.magic-link-ttl`).
(`app.auth.magic-link-ttl`). Issuing a link **does not** invalidate earlier ones — a member who
asks for a second link and then clicks the first email still gets in. Every link stands on its own
until it is used or expires, and the rate limiter is what bounds how many can be outstanding.
2. The link lands on the **frontend** verify page, which POSTs the token. Email scanners only
prefetch GETs, so they cannot burn the single-use token.
3. `POST /api/auth/verify {token}` — consumes the token atomically
Expand Down Expand Up @@ -77,30 +77,33 @@ just dev # backend :8080 (dev profile) + frontend :5173
1. Create a test member (only needed until the sign-up form is wired to the backend):
```bash
psql -h localhost -U postgres -d patchats -c \
"INSERT INTO members (id, email, full_name, introduction, active) \
VALUES (gen_random_uuid(), 'you@example.com', 'You', 'Testing locally', TRUE);"
"INSERT INTO members (id, first_name, last_name, email, introduction, active) \
VALUES (gen_random_uuid(), 'You', 'Tester', 'you@example.com', 'Testing locally', TRUE);"
```
2. Open `http://localhost:5173/login`, submit that email. (An **unregistered** email shows the same
generic panel, but the backend log shows no email composed — just the info-level skip.)
2. Open `http://localhost:5173/login`, submit that email. (An **unregistered** email instead gets a
404 and the "No account for that email" panel, with a link to `/sign-up`; the backend log shows
no email composed.)
3. The dev profile does not send real email — `LoggingEmailSender` prints the full body to the
**backend terminal**. Copy the `http://localhost:5173/auth/verify?token=...` URL from the log.
4. Open it: you land on `/`. Check DevTools → Application → Cookies for `patchats_session`
(httpOnly, Lax, not Secure in dev).
5. Open the same link again → "invalid or expired" (single-use). Requesting a second link
invalidates the first. A 4th rapid request for the same email → the login page shows the 429
message ("too many sign-in requests"), whether or not the email is registered.
5. Open the same link again → "invalid or expired" (single-use). Request a **second** link before
using the first, then open the first: it still signs you in — outstanding links are not
invalidated by a new one. A 4th rapid request for the same email → the login page shows the 429
message ("too many sign-in requests"), whether or not the email is registered — an unregistered
address hits the 429 before the 404, which is the ordering that keeps probing throttled.
6. Log out from the header (visible on guarded pages like `/sample`); guarded routes now redirect
to `/login`.

## Configuration

| Property | Env var | Default | Meaning |
| ------------------------ | -------------------- | ----------------------- | ---------------------------------------- |
| `app.auth.base-url` | `APP_BASE_URL` | `http://localhost:5173` | Public SPA origin used in emailed links |
| `app.auth.cookie-secure` | `AUTH_COOKIE_SECURE` | `true` (`false` in dev) | `Secure` flag on the session cookie |
| `app.auth.magic-link-ttl`| — | `15m` | Link validity window |
| `spring.session.timeout` | — | `30d` | Session inactivity timeout |
| Property | Env var | Default | Meaning |
| ------------------------- | -------------------- | ----------------------- | --------------------------------------- |
| `app.auth.base-url` | `APP_BASE_URL` | `http://localhost:5173` | Public SPA origin used in emailed links |
| `app.auth.cookie-secure` | `AUTH_COOKIE_SECURE` | `true` (`false` in dev) | `Secure` flag on the session cookie |
| `app.auth.magic-link-ttl` | — | `15m` | Link validity window |
| `spring.session.timeout` | — | `30d` | Session inactivity timeout |

Schema lives in Flyway (`db/migration/V0005`–`V0006`); `spring.session.jdbc.initialize-schema` is
Schema lives in Flyway (`db/migration/V0006`–`V0007`); `spring.session.jdbc.initialize-schema` is
`never` so the app never races migrations, and runtime Flyway is disabled (migrations stay
out-of-band via `just migrate`).
24 changes: 13 additions & 11 deletions docs/email-async/01-async-send-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ actually deliver it — fully functional and testable via API + dev-profile logg
See [00-overview.md](00-overview.md) for full context and the decision table.

## Decisions that apply here
- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table *is* the queue.

- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table _is_ the queue.
- **Render at send-time** (#4) — store `template_id` + `template_values`; the runner renders `subject`/`body` per
row just before sending (rendered text is **not** stored).
- **Single instance** (#5) — no row-locking needed; deploys must be **stop-then-start**.
Expand Down Expand Up @@ -84,12 +85,14 @@ except where noted). Create `@Repository` classes:
- `EmailTemplateRepo` — **read/list only** here (`findById`, `findAll`); writes arrive in Increment 5.

Pattern:

```java
jdbcClient.sql("SELECT * FROM email_templates WHERE id = :id")
.param("id", id)
.query(new EmailTemplateRowMapper())
.optional();
```

Map `JSONB` (`template_values`) ↔ Java via a small Jackson helper. For the **N-child batch insert**, drop to
the underlying `JdbcTemplate.batchUpdate(...)` (JdbcClient has no batch API yet) — inject `JdbcTemplate`
only in `EmailRepo` for that one method.
Expand All @@ -110,8 +113,8 @@ only in `EmailRepo` for that one method.
happens later, in the runner (1e).
- **The service does not start the runner.** After the `202` returns (transaction committed), the **caller**
kicks the drain via `POST /api/email/process` (see below). This is the "manual/frontend kick only" model (#6).
- *(Optional)* dry-run render at enqueue for **early validation only** — reject a template that can't render up
front. Only the *values* are stored, never the output. Skip it for the minimal path; otherwise render errors
- _(Optional)_ dry-run render at enqueue for **early validation only** — reject a template that can't render up
front. Only the _values_ are stored, never the output. Skip it for the minimal path; otherwise render errors
surface asynchronously as `ERROR` rows.
- **Endpoints** (new `EmailAsyncController` or extend the existing controller):
- `POST /api/email/send/async` → `enqueue(...)` with `source=MANUAL` → **`202 Accepted`** `{ requestId, accepted }`.
Expand All @@ -136,9 +139,9 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos

- **Bean:** a single-thread `ThreadPoolTaskExecutor` named `emailDrainExecutor` (core=max=1 so drains
serialize and overlapping triggers coalesce), configured with `setWaitForTasksToCompleteOnShutdown(true)`
+ an await timeout. `@EnableAsync` is already present on
[PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no**
`@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries).
- an await timeout. `@EnableAsync` is already present on
[PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no**
`@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries).
- **`EmailDrainer.trigger()`** submits a drain job to `emailDrainExecutor` **only if one isn't already
running** — guard with an `AtomicBoolean` via `compareAndSet`; if `trigger()` fires while a drain is
running, set a `rerun` flag so the current drain loops again instead of exiting.
Expand All @@ -148,9 +151,6 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos
committed, the rows are already visible — no `AFTER_COMMIT` event is needed. **There is no automatic
enqueue-time trigger** (the accepted tradeoff of #6: if the kick is never issued, the batch waits for the next
kick or a restart).
2. **On startup** — `@EventListener(ApplicationReadyEvent.class)` first resets `PROCESSING → ERROR`
(at-most-once recovery), then calls `trigger()` once (covers rows left `PENDING` before shutdown). This is the
only safety net for a missed kick.
- **Drain job** (runs on the executor thread, loops until no rows, then the thread idles):
1. **Claim** up to 50 `PENDING` rows atomically:
```sql
Expand All @@ -165,8 +165,8 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos
[`EmailSender.send`](../../src/main/java/org/patinanetwork/patchats/email/EmailSender.java). On success →
`status='SENT'`, `sent_at=now()` (**commit per row** — keeps any duplicate window to ≤1 email). On failure —
including a **render failure** (template edited into an invalid state, missing variable) — → `status='ERROR'`,
`error_message=ex.getMessage()` (**no retry**). *(Cache templates per drain to avoid reloading the same one
for every row in a batch.)*
`error_message=ex.getMessage()` (**no retry**). _(Cache templates per drain to avoid reloading the same one
for every row in a batch.)_
3. Re-claim; when a claim returns 0 rows, stop (honor the `rerun` flag if set).

**Runner tradeoffs:** on-demand kick (manual/frontend API request) + single-instance + sequential is chosen
Expand All @@ -181,6 +181,7 @@ or ShedLock for multi-instance.
---

## Files to touch

- **Create:** `db/migration/V0004__Create_email_tables.sql`; `email/` — `EmailRepo`,
`EmailRequestRepo`, `EmailTemplateRepo`, row mappers, a Jackson JSONB helper;
`EmailEnqueueService`, `dto/EnqueueEmailRequest`, `dto/EnqueueEmailResponse`; `RecipientSource` (+ CSV impl);
Expand All @@ -191,6 +192,7 @@ or ShedLock for multi-instance.
(add `/send/async`, `/templates` list, update `/preview`).

## Verification

- **Unit** (fake `EmailSender`, like
[EmailServiceTest](../../src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java)):
- `EmailEnqueueServiceTest` — enqueue stores `template_id` + `template_values` (no rendered output); an unknown
Expand Down
34 changes: 33 additions & 1 deletion js/src/features/auth/Login.page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { rateLimitedResponse } from "@/features/auth/api/auth.mock";
import {
rateLimitedResponse,
unregisteredEmailResponse,
} from "@/features/auth/api/auth.mock";
import LoginPage from "@/features/auth/Login.page";
import { renderWithProviders, screen } from "@/lib/test/render";
import { server } from "@/lib/test/server";
Expand All @@ -20,6 +23,35 @@ test("rejects an invalid email without calling the API", async () => {
).toBeInTheDocument();
});

test("offers sign-up and a retry when the email has no account", async () => {
server.use(
http.post("/api/auth/request-link", () => unregisteredEmailResponse()),
);
const user = userEvent.setup();
renderWithProviders(<LoginPage />);

await user.type(screen.getByLabelText(/email/i), "stranger@example.com");
await user.click(
screen.getByRole("button", { name: /email me a sign-in link/i }),
);

expect(
await screen.findByText("No account for that email"),
).toBeInTheDocument();
expect(screen.getByText("stranger@example.com")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /complete the sign-up form/i }),
).toHaveAttribute("href", "/sign-up");

await user.click(
screen.getByRole("button", { name: /try a different email/i }),
);

expect(await screen.findByLabelText(/email/i)).toHaveValue(
"stranger@example.com",
);
});

test("shows the generic check-your-email panel after submitting", async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />);
Expand Down
41 changes: 37 additions & 4 deletions js/src/features/auth/Login.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import { zodResolver } from "mantine-form-zod-resolver";
import { Link } from "react-router-dom";

/**
* Passwordless login: ask for an email, request a magic link, and show the
* same "check your email" panel no matter what — account existence is never
* revealed here.
* Passwordless login: ask for an email and request a magic link. Three states —
* the form, "check your email" once a link is on its way, and a dead end for an
* address with no account, which offers the only two ways forward (another
* address, or sign up).
*/
export default function LoginPage() {
const requestLink = useRequestLink();
Expand All @@ -28,18 +29,25 @@ export default function LoginPage() {
validate: zodResolver(loginSchema),
});

const submittedEmail = form.getValues().email.trim();

const handleSubmit = form.onSubmit((values) => {
requestLink.mutate(values.email.trim());
});

/** The backend 404s an email with no member row; every other failure falls
* through to the alert inside the form. */
const isUnregistered =
requestLink.error instanceof ApiError && requestLink.error.status === 404;

if (requestLink.isSuccess) {
return (
<Stack gap="md">
<Title order={2}>Check your email</Title>
<Text>
If you entered a valid address, a sign-in link is on its way to{" "}
<Text component="span" fw={700}>
{form.getValues().email.trim()}
{submittedEmail}
</Text>
. The link expires in 15 minutes and can only be used once.
</Text>
Expand All @@ -60,6 +68,31 @@ export default function LoginPage() {
);
}

if (isUnregistered) {
return (
<Stack gap="md">
<Title order={2}>No account for that email</Title>
<Text>
We couldn&apos;t find a PatChats account for{" "}
<Text component="span" fw={700}>
{submittedEmail}
</Text>
. Sign-in links are only sent to registered members.
</Text>
<Button onClick={() => requestLink.reset()}>
Try a different email
</Button>
<Text c="dimmed" size="sm">
Never signed up?{" "}
<Anchor component={Link} to="/sign-up" inherit>
Complete the sign-up form
</Anchor>{" "}
to join.
</Text>
</Stack>
);
}

return (
<Paper p="lg" withBorder>
<form onSubmit={handleSubmit} noValidate>
Expand Down
11 changes: 10 additions & 1 deletion js/src/features/auth/api/auth.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw";

/**
* MSW handlers for the auth domain, envelope-shaped like the real backend.
* Defaults: request-link succeeds generically, verify signs in a member, and
* Defaults: request-link succeeds, verify signs in a member, and
* there is no session (401). Tests override per case with `server.use(...)`
* and the exported fixtures.
*/
Expand All @@ -25,6 +25,15 @@ export const invalidLinkResponse = () =>
{ status: 400 },
);

export const unregisteredEmailResponse = () =>
HttpResponse.json(
{
success: false,
message: "We couldn't find an account for that email.",
},
{ status: 404 },
);

export const rateLimitedResponse = () =>
HttpResponse.json(
{
Expand Down
13 changes: 13 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- <dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableAsync
@EnableScheduling
@Slf4j
public class PatChatsApplication {

Expand Down
Loading
Loading