diff --git a/apps/api/src/notify/email-notifier.ts b/apps/api/src/notify/email-notifier.ts
index 1bcd5bc..40144b1 100644
--- a/apps/api/src/notify/email-notifier.ts
+++ b/apps/api/src/notify/email-notifier.ts
@@ -18,9 +18,15 @@ import type {
HelpWantedFillNotification,
HelpWantedInterestNotification,
Notifier,
+ PasswordResetNotification,
WelcomeNotification,
} from './index.js';
-import { renderFilledEmail, renderInterestEmail, renderWelcomeEmail } from './templates.js';
+import {
+ renderFilledEmail,
+ renderInterestEmail,
+ renderPasswordResetEmail,
+ renderWelcomeEmail,
+} from './templates.js';
export interface EmailNotifierOptions {
/** Resend client (constructed at boot with the API key from env). */
@@ -139,6 +145,44 @@ export class EmailNotifier implements Notifier {
}
}
+ async notifyPasswordReset(n: PasswordResetNotification): Promise<{ delivered: boolean }> {
+ if (!n.email) {
+ this.#log.warn(
+ { kind: 'auth.password-reset', slug: n.slug },
+ 'password-reset: no email address; skipped',
+ );
+ return { delivered: false };
+ }
+ const tpl = renderPasswordResetEmail(n, this.#siteHost);
+ try {
+ const result = await this.#resend.emails.send({
+ from: this.#from,
+ to: n.email,
+ subject: tpl.subject,
+ text: tpl.text,
+ html: tpl.html,
+ });
+ if (result.error) {
+ this.#log.error(
+ { kind: 'auth.password-reset', err: result.error, slug: n.slug },
+ 'password-reset: Resend reported delivery failure',
+ );
+ return { delivered: false };
+ }
+ this.#log.info(
+ { kind: 'auth.password-reset', slug: n.slug, resendId: result.data?.id },
+ 'password-reset: email queued for delivery',
+ );
+ return { delivered: true };
+ } catch (err) {
+ this.#log.error(
+ { kind: 'auth.password-reset', err, slug: n.slug },
+ 'password-reset: email send threw',
+ );
+ return { delivered: false };
+ }
+ }
+
async notifyHelpWantedFilled(
n: HelpWantedFillNotification,
): Promise<{ delivered: boolean }> {
diff --git a/apps/api/src/notify/index.ts b/apps/api/src/notify/index.ts
index 91a9eb2..2d98fcc 100644
--- a/apps/api/src/notify/index.ts
+++ b/apps/api/src/notify/index.ts
@@ -42,10 +42,26 @@ export interface WelcomeNotification {
readonly slug: string;
}
+/**
+ * Password-reset notification — fires from `POST /api/auth/password-reset/request`
+ * when the requested user resolves to a Person with an email on file. The
+ * `token` is the plaintext (the private store keeps only its hash); it
+ * leaves the system only via this email send.
+ */
+export interface PasswordResetNotification {
+ readonly email: string;
+ readonly fullName: string;
+ readonly slug: string;
+ readonly token: string;
+ /** Expiry timestamp (ISO 8601), surfaced in the email body. */
+ readonly expiresAt: string;
+}
+
export interface Notifier {
notifyHelpWantedInterest(n: HelpWantedInterestNotification): Promise<{ delivered: boolean }>;
notifyHelpWantedFilled(n: HelpWantedFillNotification): Promise<{ delivered: boolean }>;
notifyWelcomeOnSignup(n: WelcomeNotification): Promise<{ delivered: boolean }>;
+ notifyPasswordReset(n: PasswordResetNotification): Promise<{ delivered: boolean }>;
}
/**
@@ -73,4 +89,15 @@ export class LoggingNotifier implements Notifier {
this.#log.info({ kind: 'auth.welcome', ...n }, 'welcome notification');
return { delivered: true };
}
+
+ async notifyPasswordReset(n: PasswordResetNotification): Promise<{ delivered: boolean }> {
+ // Log the slug + email but NOT the token — even in dev logs, we don't
+ // want plaintext password-reset tokens persisted anywhere. The token
+ // is visible via the live email channel only.
+ this.#log.info(
+ { kind: 'auth.password-reset', email: n.email, slug: n.slug, expiresAt: n.expiresAt },
+ 'password-reset notification (token redacted)',
+ );
+ return { delivered: true };
+ }
}
diff --git a/apps/api/src/notify/templates.ts b/apps/api/src/notify/templates.ts
index 3adfc0a..6533824 100644
--- a/apps/api/src/notify/templates.ts
+++ b/apps/api/src/notify/templates.ts
@@ -9,6 +9,7 @@
import type {
HelpWantedFillNotification,
HelpWantedInterestNotification,
+ PasswordResetNotification,
WelcomeNotification,
} from './index.js';
@@ -188,3 +189,82 @@ export function renderFilledEmail(
return { subject, text, html };
}
+
+export interface PasswordResetTemplate {
+ readonly subject: string;
+ readonly text: string;
+ readonly html: string;
+}
+
+/**
+ * Render a password-reset email. The `siteHost` is needed to build the
+ * reset link; the token rides in the URL fragment so it doesn't end up
+ * in HTTP referer headers when the user clicks through.
+ *
+ * Per specs/api/auth.md `POST /api/auth/password-reset/request`.
+ */
+export function renderPasswordResetEmail(
+ n: PasswordResetNotification,
+ siteHost: string,
+): PasswordResetTemplate {
+ const resetUrl = `https://${siteHost}/login/reset?token=${encodeURIComponent(n.token)}`;
+ const subject = `Reset your Code for Philly password`;
+ const expiresDate = new Date(n.expiresAt);
+ const expiresHuman = expiresDate.toLocaleString('en-US', {
+ timeZone: 'UTC',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+
+ const text = [
+ `Hey ${n.fullName},`,
+ '',
+ `Someone requested a password reset for your Code for Philly account.`,
+ `If that was you, click the link below to set a new password.`,
+ `If it wasn't, you can safely ignore this email — your account is fine.`,
+ '',
+ `Reset link: ${resetUrl}`,
+ '',
+ `This link expires at ${expiresHuman} UTC (about 1 hour from when we sent it).`,
+ '',
+ `If you've forgotten your password and have a GitHub account that uses the same email,`,
+ `you can also sign in directly with GitHub at https://${siteHost}/login.`,
+ '',
+ `— Code for Philly`,
+ ].join('\n');
+
+ const html = `
+
+
+
Hey ${escapeHtml(n.fullName)},
+
+ Someone requested a password reset for your Code for Philly account.
+ If that was you, click the button below to set a new password.
+ If it wasn't, you can safely ignore this email — your account is fine.
+
+ This link expires at ${escapeHtml(expiresHuman)} UTC (about 1 hour from when we sent it).
+
+
+
+ Already have a GitHub account that uses the same email? You can sign in
+ directly with GitHub at https://${siteHost}/login
+ instead.
+
+
+`;
+
+ return { subject, text, html };
+}
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 9865f84..f6851d2 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -10,6 +10,7 @@
* GET /api/auth/sessions
* POST /api/auth/sessions/:jti/revoke
*/
+import { createHash, randomBytes } from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { errors as JoseErrors } from 'jose';
import { ok } from '../lib/response.js';
@@ -30,7 +31,7 @@ import {
verifyLegacyPassword,
} from '../auth/legacy-password.js';
import type { SessionMeta } from '../auth/session-metadata.js';
-import type { LegacyPasswordCredential } from '@cfp/shared/schemas';
+import type { LegacyPasswordCredential, PasswordToken } from '@cfp/shared/schemas';
import {
generateCsrfState,
generatePkceVerifier,
@@ -377,6 +378,168 @@ export async function authRoutes(fastify: FastifyInstance): Promise {
},
);
+ // ---------------------------------------------------------------------------
+ // POST /api/auth/password-reset/request — email a one-time reset link
+ // ---------------------------------------------------------------------------
+ //
+ // Spec: specs/api/auth.md `POST /api/auth/password-reset/request`. Always
+ // returns 202 regardless of whether the address resolved — anti-enumeration.
+ // The notifier send is fire-and-forget so wall-clock timing across all
+ // "did nothing" branches matches the "queued an email" branch.
+ // ---------------------------------------------------------------------------
+
+ fastify.post(
+ '/api/auth/password-reset/request',
+ {
+ schema: {
+ tags: ['auth'],
+ summary: 'Request a password-reset link',
+ body: {
+ type: 'object',
+ properties: {
+ usernameOrEmail: { type: 'string', minLength: 1 },
+ },
+ required: ['usernameOrEmail'],
+ additionalProperties: false,
+ },
+ },
+ },
+ async (request, reply) => {
+ const { usernameOrEmail } = request.body as { usernameOrEmail: string };
+ const trimmed = usernameOrEmail.trim();
+
+ // Resolve to a personId. Same convention as /api/auth/login: slug
+ // first, then email if it looks like one.
+ let personId = fastify.inMemoryState.personIdBySlug.get(trimmed.toLowerCase()) ?? null;
+ if (!personId && trimmed.includes('@')) {
+ personId = await fastify.store.private.findPersonIdByEmail(trimmed.toLowerCase());
+ }
+
+ // Three silent no-ops, all converging to 202: unresolved person,
+ // deleted person, or no LegacyPasswordCredential on file. The last
+ // matters because specs/api/auth.md § Notes guarantees that
+ // password-reset never *creates* a credential for a person who
+ // doesn't already have one — GitHub-only signups can't reset into
+ // a password account.
+ const person = personId ? fastify.inMemoryState.people.get(personId) : null;
+ const cred = personId ? await fastify.store.private.getLegacyPassword(personId) : null;
+ const profile = personId ? await fastify.store.private.getProfile(personId) : null;
+
+ if (personId && person && !person.deletedAt && cred && profile?.email) {
+ const plaintext = randomBytes(32).toString('base64url');
+ const tokenHash = createHash('sha256').update(plaintext).digest('hex');
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + 60 * 60 * 1000);
+ const tokenRecord: PasswordToken = {
+ tokenHash,
+ personId,
+ issuedAt: now.toISOString(),
+ expiresAt: expiresAt.toISOString(),
+ usedAt: null,
+ };
+ await fastify.store.private.putPasswordToken(tokenRecord);
+
+ // Fire-and-forget — never block the response on Resend latency.
+ void fastify.notifier
+ .notifyPasswordReset({
+ email: profile.email,
+ fullName: person.fullName,
+ slug: person.slug,
+ token: plaintext,
+ expiresAt: expiresAt.toISOString(),
+ })
+ .catch((err) => {
+ fastify.log.error({ err, slug: person.slug }, 'password-reset notification threw');
+ });
+ }
+
+ return reply.code(202).send(ok({ delivered: true }));
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // POST /api/auth/password-reset/confirm — consume token + set new password
+ // ---------------------------------------------------------------------------
+ //
+ // Spec: specs/api/auth.md `POST /api/auth/password-reset/confirm`. All
+ // failure modes — unknown token, expired token, already-used token,
+ // missing person, missing credential — collapse to a uniform 401
+ // `invalid_token` so an attacker can't distinguish.
+ //
+ // Successful confirm mints a session with loginMethod 'password_reset'
+ // so the SPA can recognize this path and surface the "link GitHub for
+ // faster sign-in" prompt on the first /account view.
+ // ---------------------------------------------------------------------------
+
+ fastify.post(
+ '/api/auth/password-reset/confirm',
+ {
+ schema: {
+ tags: ['auth'],
+ summary: 'Confirm a password reset',
+ body: {
+ type: 'object',
+ properties: {
+ token: { type: 'string', minLength: 1 },
+ password: { type: 'string', minLength: 8 },
+ },
+ required: ['token', 'password'],
+ additionalProperties: false,
+ },
+ },
+ },
+ async (request, reply) => {
+ const { token, password } = request.body as { token: string; password: string };
+
+ const tokenHash = createHash('sha256').update(token).digest('hex');
+ const record = await fastify.store.private.getPasswordToken(tokenHash);
+
+ const now = new Date();
+ if (!record || record.usedAt || new Date(record.expiresAt) <= now) {
+ throw new UnauthenticatedError('Invalid or expired token', 'invalid_token');
+ }
+
+ const person = fastify.inMemoryState.people.get(record.personId);
+ if (!person || person.deletedAt) {
+ throw new UnauthenticatedError('Invalid or expired token', 'invalid_token');
+ }
+
+ const existing = await fastify.store.private.getLegacyPassword(record.personId);
+ if (!existing) {
+ // Per specs/api/auth.md § Notes: password-reset never *creates*
+ // a credential for a person who doesn't already have one.
+ throw new UnauthenticatedError('Invalid or expired token', 'invalid_token');
+ }
+
+ const newHash = await rehashPassword(password);
+ const updated: LegacyPasswordCredential = {
+ ...existing,
+ passwordHash: newHash,
+ lastUsedAt: now.toISOString(),
+ };
+ await fastify.store.private.putLegacyPassword(updated);
+
+ const consumed: PasswordToken = { ...record, usedAt: now.toISOString() };
+ await fastify.store.private.putPasswordToken(consumed);
+
+ const tokens = await issueSession(
+ record.personId,
+ person.accountLevel,
+ fastify.config.CFP_JWT_SIGNING_KEY,
+ { loginMethod: 'password_reset' },
+ );
+ await persistSessionMetadata(fastify, request, tokens.refreshJti, record.personId);
+
+ setSessionCookies(
+ reply,
+ { access: tokens.access, refresh: tokens.refresh },
+ fastify.config.NODE_ENV,
+ );
+
+ return ok({ person });
+ },
+ );
+
// ---------------------------------------------------------------------------
// POST /api/auth/refresh — mint new access+refresh pair from refresh cookie
// ---------------------------------------------------------------------------
diff --git a/apps/api/src/store/private/base.ts b/apps/api/src/store/private/base.ts
index 1c42df3..591f0bd 100644
--- a/apps/api/src/store/private/base.ts
+++ b/apps/api/src/store/private/base.ts
@@ -1,11 +1,13 @@
import {
AccountClaimRequestSchema,
LegacyPasswordCredentialSchema,
+ PasswordTokenSchema,
PrivateProfileSchema,
} from '@cfp/shared/schemas';
import type {
AccountClaimRequest,
LegacyPasswordCredential,
+ PasswordToken,
PrivateProfile,
} from '@cfp/shared/schemas';
import type { PrivateIndices, PrivateStore, PrivateStoreTx } from './interface.js';
@@ -19,6 +21,7 @@ import type { PrivateIndices, PrivateStore, PrivateStoreTx } from './interface.j
export abstract class BasePrivateStore implements PrivateStore {
protected profiles: Map = new Map();
protected legacyPasswords: Map = new Map();
+ protected passwordTokens: Map = new Map();
protected claimRequests: Map = new Map();
readonly indices: PrivateIndices = {
@@ -36,6 +39,7 @@ export abstract class BasePrivateStore implements PrivateStore {
await Promise.all([
this.loadProfiles(),
this.loadLegacyPasswords(),
+ this.loadPasswordTokens(),
this.loadClaimRequests(),
]);
this.rebuildIndices();
@@ -60,6 +64,11 @@ export abstract class BasePrivateStore implements PrivateStore {
this.claimRequests = parseJsonl(raw, AccountClaimRequestSchema, 'id');
}
+ private async loadPasswordTokens(): Promise {
+ const raw = await this.readRaw('password-tokens.jsonl');
+ this.passwordTokens = parseJsonl(raw, PasswordTokenSchema, 'tokenHash');
+ }
+
private rebuildIndices(): void {
this.indices.byEmail.clear();
this.indices.byUnsubscribeToken.clear();
@@ -119,6 +128,21 @@ export abstract class BasePrivateStore implements PrivateStore {
return this.legacyPasswords.size;
}
+ async getPasswordToken(tokenHash: string): Promise {
+ return this.passwordTokens.get(tokenHash) ?? null;
+ }
+
+ async putPasswordToken(token: PasswordToken): Promise {
+ const parsed = PasswordTokenSchema.parse(token);
+ this.passwordTokens.set(parsed.tokenHash, parsed);
+ await this.flushPasswordTokens();
+ }
+
+ async deletePasswordToken(tokenHash: string): Promise {
+ this.passwordTokens.delete(tokenHash);
+ await this.flushPasswordTokens();
+ }
+
async getClaimRequest(requestId: string): Promise {
return this.claimRequests.get(requestId) ?? null;
}
@@ -258,12 +282,17 @@ export abstract class BasePrivateStore implements PrivateStore {
const lines = [...this.claimRequests.values()].map((r) => JSON.stringify(r)).join('\n');
await this.writeRaw('account-claim-requests.jsonl', lines ? lines + '\n' : '');
}
+
+ protected async flushPasswordTokens(): Promise {
+ const lines = [...this.passwordTokens.values()].map((t) => JSON.stringify(t)).join('\n');
+ await this.writeRaw('password-tokens.jsonl', lines ? lines + '\n' : '');
+ }
}
function parseJsonl(
raw: string | null,
schema: { parse: (input: unknown) => T },
- keyField: 'personId' | 'id' = 'personId',
+ keyField: 'personId' | 'id' | 'tokenHash' = 'personId',
): Map {
const map = new Map();
if (!raw) return map;
diff --git a/apps/api/src/store/private/interface.ts b/apps/api/src/store/private/interface.ts
index 5b2f41d..d0cfd39 100644
--- a/apps/api/src/store/private/interface.ts
+++ b/apps/api/src/store/private/interface.ts
@@ -1,6 +1,7 @@
import type {
AccountClaimRequest,
LegacyPasswordCredential,
+ PasswordToken,
PrivateProfile,
} from '@cfp/shared/schemas';
@@ -54,6 +55,11 @@ export interface PrivateStore {
deleteLegacyPassword(personId: string): Promise;
countLegacyPasswords(): Promise;
+ // --- Password-reset tokens ---
+ getPasswordToken(tokenHash: string): Promise;
+ putPasswordToken(token: PasswordToken): Promise;
+ deletePasswordToken(tokenHash: string): Promise;
+
// --- Account-claim requests ---
getClaimRequest(requestId: string): Promise;
putClaimRequest(req: AccountClaimRequest): Promise;
diff --git a/apps/api/tests/auth-password-reset.test.ts b/apps/api/tests/auth-password-reset.test.ts
new file mode 100644
index 0000000..a247d3a
--- /dev/null
+++ b/apps/api/tests/auth-password-reset.test.ts
@@ -0,0 +1,450 @@
+/**
+ * Tests for POST /api/auth/password-reset/{request,confirm} per
+ * specs/api/auth.md + specs/behaviors/account-migration.md.
+ *
+ * The request endpoint is intentionally enumeration-safe: every path
+ * returns 202 regardless of whether the address resolved. The tests
+ * verify that the *side effect* (a `PasswordToken` record on disk)
+ * happens only in the resolvable + has-credential + has-email branch.
+ *
+ * The confirm endpoint seeds a token directly via the store so the
+ * tests don't have to round-trip the plaintext through the notifier.
+ */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { type FastifyInstance } from 'fastify';
+import { createHash, randomBytes } from 'node:crypto';
+import { writeFile, readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import { buildApp } from '../src/app.js';
+import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js';
+import { seedRawToml } from './helpers/seed-fixtures.js';
+import { verifyLegacyPassword } from '../src/auth/legacy-password.js';
+
+const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!';
+
+let testIpCounter = 0;
+function nextTestIp(): string {
+ testIpCounter += 1;
+ return `10.6.${Math.floor(testIpCounter / 250)}.${testIpCounter % 250}`;
+}
+
+async function seedPerson(
+ repoPath: string,
+ slug: string,
+ id: string,
+ extra: Record = {},
+): Promise {
+ const fields = {
+ id,
+ slug,
+ fullName: slug
+ .split('-')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' '),
+ accountLevel: 'user',
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ ...extra,
+ };
+ const toml = Object.entries(fields)
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
+ .join('\n');
+ await seedRawToml(
+ repoPath,
+ `people/${slug}.toml`,
+ toml + '\n',
+ `seed people/${slug}`,
+ );
+}
+
+async function appendJsonl(filePath: string, record: object): Promise {
+ let content = '';
+ try {
+ content = await readFile(filePath, 'utf8');
+ } catch {
+ // first write
+ }
+ await writeFile(filePath, content + JSON.stringify(record) + '\n');
+}
+
+async function seedPrivateProfile(
+ privatePath: string,
+ personId: string,
+ email: string,
+): Promise {
+ await appendJsonl(join(privatePath, 'profiles.jsonl'), {
+ personId,
+ email,
+ emailRefreshedAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ });
+}
+
+async function seedLegacyPassword(
+ privatePath: string,
+ personId: string,
+ passwordHash: string,
+): Promise {
+ await appendJsonl(join(privatePath, 'legacy-passwords.jsonl'), {
+ personId,
+ passwordHash,
+ importedAt: '2026-05-01T00:00:00Z',
+ });
+}
+
+async function readPasswordTokens(
+ privatePath: string,
+): Promise>> {
+ try {
+ const content = await readFile(join(privatePath, 'password-tokens.jsonl'), 'utf8');
+ return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
+ } catch {
+ return [];
+ }
+}
+
+async function readLegacyPasswords(
+ privatePath: string,
+): Promise> {
+ try {
+ const content = await readFile(join(privatePath, 'legacy-passwords.jsonl'), 'utf8');
+ return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
+ } catch {
+ return [];
+ }
+}
+
+async function buildTestApp(
+ dataPath: string,
+ privatePath: string,
+): Promise {
+ return buildApp({
+ serverOptions: { logger: false },
+ overrideEnv: {
+ CFP_DATA_REPO_PATH: dataPath,
+ STORAGE_BACKEND: 'filesystem',
+ CFP_PRIVATE_STORAGE_PATH: privatePath,
+ CFP_JWT_SIGNING_KEY: JWT_KEY,
+ NODE_ENV: 'test',
+ },
+ });
+}
+
+describe('POST /api/auth/password-reset/request', () => {
+ let dataRepo: { path: string; cleanup: () => Promise };
+ let privateStore: { path: string; cleanup: () => Promise };
+ let app: FastifyInstance;
+ const fullPersonId = '01951a3c-0000-7000-8000-0000bbbbbbb1';
+ const noCredPersonId = '01951a3c-0000-7000-8000-0000bbbbbbb2';
+ const noEmailPersonId = '01951a3c-0000-7000-8000-0000bbbbbbb3';
+ const correctPassword = 'orig-pw-hunter2';
+
+ beforeAll(async () => {
+ dataRepo = await createFullDataRepo();
+ privateStore = await createPrivateStorageDir();
+
+ // The happy-path target: profile with email + legacy credential.
+ await seedPerson(dataRepo.path, 'reset-target', fullPersonId);
+ await seedPrivateProfile(privateStore.path, fullPersonId, 'target@example.com');
+ const sha1 = createHash('sha1').update(correctPassword).digest('hex');
+ await seedLegacyPassword(privateStore.path, fullPersonId, sha1);
+
+ // Has email but no legacy credential (e.g., GitHub-only signup).
+ // Reset should be a silent no-op.
+ await seedPerson(dataRepo.path, 'no-cred', noCredPersonId);
+ await seedPrivateProfile(privateStore.path, noCredPersonId, 'nocred@example.com');
+
+ // Has credential but no profile/email — reset can't deliver, so
+ // silent no-op.
+ await seedPerson(dataRepo.path, 'no-email', noEmailPersonId);
+ await seedLegacyPassword(privateStore.path, noEmailPersonId, sha1);
+
+ app = await buildTestApp(dataRepo.path, privateStore.path);
+ }, 60_000);
+
+ afterAll(async () => {
+ await app.close();
+ await dataRepo.cleanup();
+ await privateStore.cleanup();
+ });
+
+ it('always returns 202 (anti-enumeration) for an unknown account', async () => {
+ const before = await readPasswordTokens(privateStore.path);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/request',
+ remoteAddress: nextTestIp(),
+ payload: { usernameOrEmail: 'completely-nonexistent-account' },
+ });
+
+ expect(res.statusCode).toBe(202);
+ expect(res.json<{ success: true; data: { delivered: boolean } }>().data.delivered).toBe(true);
+
+ const after = await readPasswordTokens(privateStore.path);
+ expect(after.length).toBe(before.length);
+ });
+
+ it('silently no-ops when the person has no legacy credential', async () => {
+ const before = await readPasswordTokens(privateStore.path);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/request',
+ remoteAddress: nextTestIp(),
+ payload: { usernameOrEmail: 'no-cred' },
+ });
+
+ expect(res.statusCode).toBe(202);
+ const after = await readPasswordTokens(privateStore.path);
+ expect(after.length).toBe(before.length);
+ });
+
+ it('silently no-ops when the person has no email on file', async () => {
+ const before = await readPasswordTokens(privateStore.path);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/request',
+ remoteAddress: nextTestIp(),
+ payload: { usernameOrEmail: 'no-email' },
+ });
+
+ expect(res.statusCode).toBe(202);
+ const after = await readPasswordTokens(privateStore.path);
+ expect(after.length).toBe(before.length);
+ });
+
+ it('persists a PasswordToken when the target resolves with email + credential', async () => {
+ const before = await readPasswordTokens(privateStore.path);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/request',
+ remoteAddress: nextTestIp(),
+ payload: { usernameOrEmail: 'reset-target' },
+ });
+
+ expect(res.statusCode).toBe(202);
+
+ const after = await readPasswordTokens(privateStore.path);
+ expect(after.length).toBe(before.length + 1);
+ const newest = after[after.length - 1] as Record;
+ expect(newest['personId']).toBe(fullPersonId);
+ expect(newest['usedAt']).toBeNull();
+ expect(typeof newest['tokenHash']).toBe('string');
+ expect((newest['tokenHash'] as string).length).toBe(64);
+ // 1h expiry per spec
+ const expires = new Date(newest['expiresAt'] as string).getTime();
+ const issued = new Date(newest['issuedAt'] as string).getTime();
+ expect(expires - issued).toBeGreaterThanOrEqual(59 * 60 * 1000);
+ expect(expires - issued).toBeLessThanOrEqual(61 * 60 * 1000);
+ });
+
+ it('resolves usernameOrEmail by email', async () => {
+ const before = await readPasswordTokens(privateStore.path);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/request',
+ remoteAddress: nextTestIp(),
+ payload: { usernameOrEmail: 'TARGET@example.com' },
+ });
+
+ expect(res.statusCode).toBe(202);
+ const after = await readPasswordTokens(privateStore.path);
+ expect(after.length).toBe(before.length + 1);
+ });
+});
+
+describe('POST /api/auth/password-reset/confirm', () => {
+ let dataRepo: { path: string; cleanup: () => Promise };
+ let privateStore: { path: string; cleanup: () => Promise };
+ let app: FastifyInstance;
+ const personId = '01951a3c-0000-7000-8000-0000ccccccc1';
+ const noCredPersonId = '01951a3c-0000-7000-8000-0000ccccccc2';
+ const oldPassword = 'orig-pw-hunter2';
+ const newPassword = 'fresh-pw-foxtrot7';
+
+ /**
+ * Mint a token plaintext + persist its hash through the live app's
+ * private store. Writing directly to the .jsonl file would skip the
+ * boot-time in-memory map; routes only read from the map, so disk-
+ * only seeds yield false 401s. Going through the store updates both.
+ */
+ async function mintToken(
+ targetId: string,
+ opts: { expiresAt?: string; usedAt?: string } = {},
+ ): Promise {
+ const plaintext = randomBytes(32).toString('base64url');
+ const tokenHash = createHash('sha256').update(plaintext).digest('hex');
+ const now = new Date();
+ await app.store.private.putPasswordToken({
+ tokenHash,
+ personId: targetId,
+ issuedAt: now.toISOString(),
+ expiresAt: opts.expiresAt ?? new Date(now.getTime() + 60 * 60 * 1000).toISOString(),
+ usedAt: opts.usedAt ?? null,
+ });
+ return plaintext;
+ }
+
+ beforeAll(async () => {
+ dataRepo = await createFullDataRepo();
+ privateStore = await createPrivateStorageDir();
+
+ await seedPerson(dataRepo.path, 'confirm-target', personId);
+ await seedPrivateProfile(privateStore.path, personId, 'confirm@example.com');
+ const sha1 = createHash('sha1').update(oldPassword).digest('hex');
+ await seedLegacyPassword(privateStore.path, personId, sha1);
+
+ // No-credential person — confirm should reject with invalid_token
+ // even when the token itself resolves correctly.
+ await seedPerson(dataRepo.path, 'confirm-no-cred', noCredPersonId);
+ await seedPrivateProfile(privateStore.path, noCredPersonId, 'nocred-confirm@example.com');
+
+ app = await buildTestApp(dataRepo.path, privateStore.path);
+ }, 60_000);
+
+ afterAll(async () => {
+ await app.close();
+ await dataRepo.cleanup();
+ await privateStore.cleanup();
+ });
+
+ it('rejects an unknown token with 401 invalid_token', async () => {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: 'never-issued-token', password: newPassword },
+ });
+ expect(res.statusCode).toBe(401);
+ expect(res.json<{ error: { code: string } }>().error.code).toBe('invalid_token');
+ });
+
+ it('rejects an expired token with 401 invalid_token', async () => {
+ const plaintext = await mintToken(personId, {
+ expiresAt: new Date(Date.now() - 60_000).toISOString(),
+ });
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: plaintext, password: newPassword },
+ });
+ expect(res.statusCode).toBe(401);
+ expect(res.json<{ error: { code: string } }>().error.code).toBe('invalid_token');
+ });
+
+ it('rejects an already-used token with 401 invalid_token', async () => {
+ const plaintext = await mintToken(personId, {
+ usedAt: new Date(Date.now() - 60_000).toISOString(),
+ });
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: plaintext, password: newPassword },
+ });
+ expect(res.statusCode).toBe(401);
+ expect(res.json<{ error: { code: string } }>().error.code).toBe('invalid_token');
+ });
+
+ it('rejects with 401 when the person has no credential on file', async () => {
+ const plaintext = await mintToken(noCredPersonId);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: plaintext, password: newPassword },
+ });
+ expect(res.statusCode).toBe(401);
+ expect(res.json<{ error: { code: string } }>().error.code).toBe('invalid_token');
+ });
+
+ it('rejects with 422 when the new password is too short', async () => {
+ const plaintext = await mintToken(personId);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: plaintext, password: 'short' },
+ });
+ expect(res.statusCode).toBe(422);
+ });
+
+ it('succeeds: sets cookies, rotates credential, marks token used', async () => {
+ const plaintext = await mintToken(personId);
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: plaintext, password: newPassword },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = res.json<{ success: true; data: { person: { slug: string } } }>();
+ expect(body.data.person.slug).toBe('confirm-target');
+
+ const cookies = res.headers['set-cookie'];
+ const cookieStr = Array.isArray(cookies) ? cookies.join('\n') : (cookies ?? '');
+ expect(cookieStr).toContain('cfp_session=');
+ expect(cookieStr).toContain('cfp_refresh=');
+
+ // Credential rotated to argon2id + lastUsedAt populated
+ const creds = await readLegacyPasswords(privateStore.path);
+ const rotated = creds.find((c) => c.personId === personId);
+ expect(rotated).toBeDefined();
+ expect(rotated!.passwordHash).toMatch(/^\$argon2id\$/);
+ expect(rotated!.lastUsedAt).toBeDefined();
+ // The new password verifies against the new hash; the old one doesn't.
+ const newOk = await verifyLegacyPassword(newPassword, rotated!.passwordHash);
+ const oldOk = await verifyLegacyPassword(oldPassword, rotated!.passwordHash);
+ expect(newOk.valid).toBe(true);
+ expect(oldOk.valid).toBe(false);
+
+ // Token marked used
+ const tokens = await readPasswordTokens(privateStore.path);
+ const tokenHash = createHash('sha256').update(plaintext).digest('hex');
+ const consumed = tokens.find((t) => t['tokenHash'] === tokenHash);
+ expect(consumed).toBeDefined();
+ expect(consumed!['usedAt']).not.toBeNull();
+ });
+
+ it('rejects the same token a second time (single-use)', async () => {
+ const plaintext = await mintToken(personId);
+
+ const first = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: plaintext, password: 'reusable-pw-test' },
+ });
+ expect(first.statusCode).toBe(200);
+
+ const second = await app.inject({
+ method: 'POST',
+ url: '/api/auth/password-reset/confirm',
+ remoteAddress: nextTestIp(),
+ payload: { token: plaintext, password: 'something-else-1' },
+ });
+ expect(second.statusCode).toBe(401);
+ expect(second.json<{ error: { code: string } }>().error.code).toBe('invalid_token');
+ });
+
+ it('new password works for /api/auth/login (end-to-end)', async () => {
+ // The previous test rotated the credential to `newPassword`; the
+ // second test rotated again to 'reusable-pw-test'. Sign in with the
+ // most recent.
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ remoteAddress: nextTestIp(),
+ payload: { usernameOrEmail: 'confirm-target', password: 'reusable-pw-test' },
+ });
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index b9b91a5..e794b09 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -28,6 +28,8 @@ import { Contact } from '@/pages/Contact';
import { StaticPage } from '@/pages/StaticPage';
import { NotFound } from '@/pages/NotFound';
import { LoginPlaceholder } from '@/pages/LoginPlaceholder';
+import { PasswordResetRequest } from '@/pages/PasswordResetRequest';
+import { PasswordResetConfirm } from '@/pages/PasswordResetConfirm';
import { AccountClaim } from '@/pages/AccountClaim';
import { AccountClaimByPassword } from '@/pages/AccountClaimByPassword';
import { AccountClaimRequestStaffReview } from '@/pages/AccountClaimRequestStaffReview';
@@ -65,6 +67,8 @@ const router = createBrowserRouter([
{ path: '/pages/:slug', element: },
{ path: '/contact', element: },
{ path: '/login', element: },
+ { path: '/login/forgot', element: },
+ { path: '/login/reset', element: },
{ path: '/account-claim', element: },
{ path: '/account-claim/by-password', element: },
{ path: '/account-claim/request-staff-review', element: },
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 1b8f7c8..9cbe73e 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -729,6 +729,32 @@ export const api = {
method: 'POST',
body: JSON.stringify({ usernameOrEmail, password }),
}),
+ /**
+ * Request a password-reset link. Always resolves on 202 regardless
+ * of whether an account matched — the server intentionally hides
+ * that signal to prevent address enumeration.
+ */
+ passwordResetRequest: (
+ usernameOrEmail: string,
+ ): Promise> =>
+ request(`/api/auth/password-reset/request`, {
+ method: 'POST',
+ body: JSON.stringify({ usernameOrEmail }),
+ }),
+ /**
+ * Confirm a password reset with the token from email + a new password.
+ * On success, mints a session and sets cookies (just like /login).
+ * Throws ApiError with `code: "invalid_token"` if the token is
+ * unknown, expired, or already used.
+ */
+ passwordResetConfirm: (
+ token: string,
+ password: string,
+ ): Promise> =>
+ request(`/api/auth/password-reset/confirm`, {
+ method: 'POST',
+ body: JSON.stringify({ token, password }),
+ }),
sessions: (): Promise> => request(`/api/auth/sessions`),
revokeSession: (jti: string): Promise =>
request(`/api/auth/sessions/${encodeURIComponent(jti)}/revoke`, { method: 'POST' }),
diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx
index 932a1ae..50decf4 100644
--- a/apps/web/src/pages/LoginPlaceholder.tsx
+++ b/apps/web/src/pages/LoginPlaceholder.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react';
-import { useNavigate, useSearchParams } from 'react-router';
+import { Link, useNavigate, useSearchParams } from 'react-router';
import {
Card,
CardContent,
@@ -265,6 +265,13 @@ function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) {
>
{submitting ? 'Signing in…' : 'Sign in'}
+
+
+ Forgot your password?
+
);
}
diff --git a/apps/web/src/pages/PasswordResetConfirm.tsx b/apps/web/src/pages/PasswordResetConfirm.tsx
new file mode 100644
index 0000000..24056a1
--- /dev/null
+++ b/apps/web/src/pages/PasswordResetConfirm.tsx
@@ -0,0 +1,139 @@
+import { useState, type FormEvent } from 'react';
+import { Link, useNavigate, useSearchParams } from 'react-router';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useAuth } from '@/hooks/useAuth';
+import { api, ApiError } from '@/lib/api';
+
+export function PasswordResetConfirm() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const { reload } = useAuth();
+ const token = searchParams.get('token') ?? '';
+
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(
+ token
+ ? null
+ : 'This reset link is missing its token. Request a new link to continue.',
+ );
+
+ async function handleSubmit(e: FormEvent): Promise {
+ e.preventDefault();
+ if (submitting) return;
+ if (password !== confirm) {
+ setErrorMessage('The two passwords don’t match.');
+ return;
+ }
+ if (password.length < 8) {
+ setErrorMessage('Password must be at least 8 characters.');
+ return;
+ }
+ setSubmitting(true);
+ setErrorMessage(null);
+ try {
+ await api.auth.passwordResetConfirm(token, password);
+ await reload();
+ void navigate('/', { replace: true });
+ } catch (err) {
+ if (err instanceof ApiError) {
+ if (err.status === 401) {
+ setErrorMessage(
+ 'This reset link is invalid or has expired. Request a new link to continue.',
+ );
+ } else if (err.status === 429) {
+ setErrorMessage(
+ 'Too many attempts. Please wait a minute and try again.',
+ );
+ } else if (err.status === 422) {
+ setErrorMessage(
+ err.message || 'That password doesn’t meet the minimum requirements.',
+ );
+ } else {
+ setErrorMessage('Something went wrong. Please try again.');
+ }
+ } else {
+ setErrorMessage('Something went wrong. Please try again.');
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ Set a new password
+
+ Pick a new password for your Code for Philly account. You’ll
+ be signed in automatically.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/PasswordResetRequest.tsx b/apps/web/src/pages/PasswordResetRequest.tsx
new file mode 100644
index 0000000..8d3c1a6
--- /dev/null
+++ b/apps/web/src/pages/PasswordResetRequest.tsx
@@ -0,0 +1,100 @@
+import { useState, type FormEvent } from 'react';
+import { Link } from 'react-router';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { api } from '@/lib/api';
+
+export function PasswordResetRequest() {
+ const [usernameOrEmail, setUsernameOrEmail] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+
+ async function handleSubmit(e: FormEvent): Promise {
+ e.preventDefault();
+ if (submitting) return;
+ setSubmitting(true);
+ try {
+ await api.auth.passwordResetRequest(usernameOrEmail.trim());
+ } catch {
+ // The endpoint is always 202; failure here is a network problem.
+ // We still flip to the confirmation state to avoid leaking signal.
+ } finally {
+ setSubmitting(false);
+ setSubmitted(true);
+ }
+ }
+
+ return (
+
+
+
+ Reset your password
+
+ Enter the username or email you used at Code for Philly. If we find
+ an account with a password on file, we’ll email a reset link.
+
+
+
+ {submitted ? (
+
+
+ If we have an account on file matching{' '}
+ {usernameOrEmail.trim()}, we just sent a
+ password-reset link to the email address on the account.
+
+
+ The link is good for one hour. Check your spam folder if it
+ doesn’t arrive in a few minutes.
+
+
+
+ Back to sign in
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/tests/PasswordReset.test.tsx b/apps/web/tests/PasswordReset.test.tsx
new file mode 100644
index 0000000..ba783af
--- /dev/null
+++ b/apps/web/tests/PasswordReset.test.tsx
@@ -0,0 +1,161 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { renderScreen } from './test-utils.js';
+import { PasswordResetRequest } from '../src/pages/PasswordResetRequest.js';
+import { PasswordResetConfirm } from '../src/pages/PasswordResetConfirm.js';
+import { AuthProvider } from '../src/hooks/useAuth.js';
+
+describe('PasswordResetRequest', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function render() {
+ return renderScreen(
+
+
+ ,
+ { initialEntries: ['/login/forgot'] },
+ );
+ }
+
+ it('renders the request form with disabled submit when empty', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole('button', { name: /send reset link/i })).toBeDisabled();
+ });
+
+ it('shows the generic confirmation message after submit (anti-enumeration)', async () => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string, init?: RequestInit) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }
+ if (input === '/api/auth/password-reset/request' && init?.method === 'POST') {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({ success: true, data: { delivered: true }, metadata: { timestamp: '' } }),
+ { status: 202, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+
+ render();
+ await waitFor(() => {
+ expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument();
+ });
+ fireEvent.change(screen.getByLabelText(/username or email/i), {
+ target: { value: 'jane@example.com' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /send reset link/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/if we have an account on file matching/i),
+ ).toBeInTheDocument();
+ });
+ // The displayed email is the value the user entered, not anything the server confirmed.
+ expect(screen.getByText('jane@example.com')).toBeInTheDocument();
+ });
+});
+
+describe('PasswordResetConfirm', () => {
+ beforeEach(() => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function renderWithToken(token: string | null) {
+ const entry = token ? `/login/reset?token=${encodeURIComponent(token)}` : '/login/reset';
+ return renderScreen(
+
+
+ ,
+ { initialEntries: [entry] },
+ );
+ }
+
+ it('warns when the URL is missing the token query param', async () => {
+ renderWithToken(null);
+ await waitFor(() => {
+ expect(
+ screen.getByText(/this reset link is missing its token/i),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('blocks submit when the two passwords do not match', async () => {
+ renderWithToken('opaque-token');
+ await waitFor(() => {
+ expect(screen.getByLabelText(/^new password$/i)).toBeInTheDocument();
+ });
+ fireEvent.change(screen.getByLabelText(/^new password$/i), {
+ target: { value: 'longenough1' },
+ });
+ fireEvent.change(screen.getByLabelText(/^confirm new password$/i), {
+ target: { value: 'different1' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /set password and sign in/i }));
+ await waitFor(() => {
+ expect(screen.getByText(/two passwords don/i)).toBeInTheDocument();
+ });
+ });
+
+ it('renders friendly invalid-token error on 401', async () => {
+ vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string, init?: RequestInit) => {
+ if (input.startsWith('/api/auth/me')) {
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }
+ if (input === '/api/auth/password-reset/confirm' && init?.method === 'POST') {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ success: false,
+ error: { code: 'invalid_token', message: 'Invalid or expired token' },
+ }),
+ { status: 401, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 404 }));
+ }) as typeof fetch);
+
+ renderWithToken('expired-token');
+ await waitFor(() => {
+ expect(screen.getByLabelText(/^new password$/i)).toBeInTheDocument();
+ });
+ fireEvent.change(screen.getByLabelText(/^new password$/i), {
+ target: { value: 'longenough1' },
+ });
+ fireEvent.change(screen.getByLabelText(/^confirm new password$/i), {
+ target: { value: 'longenough1' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /set password and sign in/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/this reset link is invalid or has expired/i),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts
index 398e3d2..1d206fc 100644
--- a/packages/shared/src/schemas/index.ts
+++ b/packages/shared/src/schemas/index.ts
@@ -40,5 +40,8 @@ export type { PrivateProfile, Newsletter } from './private-profile.js';
export { LegacyPasswordCredentialSchema } from './legacy-password-credential.js';
export type { LegacyPasswordCredential } from './legacy-password-credential.js';
+export { PasswordTokenSchema } from './password-token.js';
+export type { PasswordToken } from './password-token.js';
+
export { AccountClaimRequestSchema } from './account-claim-request.js';
export type { AccountClaimRequest } from './account-claim-request.js';
diff --git a/packages/shared/src/schemas/password-token.ts b/packages/shared/src/schemas/password-token.ts
new file mode 100644
index 0000000..f20fac4
--- /dev/null
+++ b/packages/shared/src/schemas/password-token.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod';
+
+/**
+ * One-time password-reset token. Issued by `POST /api/auth/password-reset/request`,
+ * consumed by `POST /api/auth/password-reset/confirm`.
+ *
+ * Per specs/api/auth.md and specs/behaviors/account-migration.md:
+ * - Tokens are minted via CSPRNG and have a 1-hour expiry
+ * - Single-use — `usedAt` is set on consumption; subsequent attempts fail
+ * - Stored in the private store; the plaintext token only leaves over email
+ *
+ * The record itself stores a hash of the token (not the plaintext), so a
+ * private-store leak doesn't immediately allow an attacker to reset anyone's
+ * password. Verification: hash the user-supplied token and look up by hash.
+ */
+export const PasswordTokenSchema = z.object({
+ /** SHA-256 hex of the plaintext token. The plaintext is never persisted. */
+ tokenHash: z.string().regex(/^[0-9a-f]{64}$/),
+ personId: z.string().uuid(),
+ issuedAt: z.string().datetime({ offset: true }),
+ expiresAt: z.string().datetime({ offset: true }),
+ /** ISO timestamp when the token was consumed; `null` while still valid. */
+ usedAt: z.string().datetime({ offset: true }).nullable().optional(),
+});
+
+export type PasswordToken = z.infer;
diff --git a/plans/login-migration-impl-phase-c.md b/plans/login-migration-impl-phase-c.md
new file mode 100644
index 0000000..6ce196d
--- /dev/null
+++ b/plans/login-migration-impl-phase-c.md
@@ -0,0 +1,125 @@
+---
+status: done
+depends: [login-migration-impl-phase-b]
+specs:
+ - specs/api/auth.md
+ - specs/behaviors/account-migration.md
+ - specs/behaviors/password-hash-rotation.md
+issues: []
+pr: 120
+---
+
+# Plan: login-migration impl — phase C (password reset)
+
+## Scope
+
+Third phase of the [login-migration-strategy](./login-migration-strategy.md) implementation track. Adds email-token-based password recovery for legacy-credential accounts, plus the SPA affordance that gets users into the flow.
+
+What ships:
+
+- **`PasswordToken` private record** — one-time, SHA-256-hashed, 1-hour expiry. The plaintext token leaves the system only via the email send; the store only ever holds the hash.
+- **`POST /api/auth/password-reset/request`** — always 202, fire-and-forget notifier send, anti-enumeration silent no-op when target is unresolvable / has no email / has no credential.
+- **`POST /api/auth/password-reset/confirm`** — single-use token, uniform 401 on all rejection paths, rehashes new password to argon2id, rotates the credential, mints a session with `loginMethod: 'password_reset'`.
+- **`EmailNotifier.notifyPasswordReset`** + LoggingNotifier impl + email template (text + HTML).
+- **SPA** — "Forgot your password?" link on the legacy-password form, `/login/forgot` request page, `/login/reset?token=...` confirm page.
+
+## Implements
+
+- [api/auth.md](../specs/api/auth.md) — both password-reset endpoints + the `PasswordToken` data shape.
+- [behaviors/account-migration.md](../specs/behaviors/account-migration.md) — recover-by-email path.
+- [behaviors/password-hash-rotation.md](../specs/behaviors/password-hash-rotation.md) — reset target rehashes to argon2id at current params (same `rehashPassword` from phase A).
+
+## Approach
+
+### 1. `PasswordToken` schema + private-store wiring
+
+- New Zod schema at `packages/shared/src/schemas/password-token.ts`: `{ tokenHash, personId, issuedAt, expiresAt, usedAt }`. `tokenHash` is the SHA-256 hex of the plaintext.
+- `BasePrivateStore` adds a `passwordTokens: Map` keyed by `tokenHash`, with `load() → readRaw('password-tokens.jsonl')` + `flushPasswordTokens()` mirroring the existing record types.
+- `PrivateStore` interface adds `getPasswordToken`, `putPasswordToken`, `deletePasswordToken`. Not added to `PrivateStoreTx` — both routes call the direct methods (no cross-store atomicity needed).
+- `parseJsonl` keyField type extended to include `'tokenHash'` since tokens are keyed by their hash, not `personId` or `id`.
+
+### 2. Notifier integration
+
+- `Notifier` interface gains `notifyPasswordReset(n: PasswordResetNotification): Promise<{ delivered: boolean }>`. Shape: `{ email, fullName, slug, token, expiresAt }`.
+- `LoggingNotifier` impl logs `{ slug, email, expiresAt }` but **never** the token. Even in dev logs, plaintext password-reset tokens stay out of any persisted log stream.
+- `EmailNotifier` impl mirrors `notifyWelcomeOnSignup` — Resend `emails.send` with `from`/`to`/`subject`/`text`/`html`; log on send / failure / throw; never throws to the caller.
+- `renderPasswordResetEmail` builds the URL `https://${host}/login/reset?token=`, subject `"Reset your Code for Philly password"`, body mentions 1-hour expiry + GitHub-sign-in alternative.
+
+### 3. `POST /api/auth/password-reset/request`
+
+Inserted after `/api/auth/login` in `apps/api/src/routes/auth.ts`. Pipeline:
+
+1. Resolve `usernameOrEmail` via `personIdBySlug`, then `findPersonIdByEmail` (same convention as `/login`).
+2. Look up `person`, `cred` (legacy password), `profile`. Silent no-op (still 202) when:
+ - Person doesn't resolve / is deleted, OR
+ - No legacy credential (so password-reset can't *create* a credential — spec invariant), OR
+ - No email on file.
+3. Generate `randomBytes(32).toString('base64url')` as the plaintext, SHA-256 it for `tokenHash`, persist `PasswordToken` with 1-hour `expiresAt`.
+4. Fire-and-forget `notifier.notifyPasswordReset({ email, fullName, slug, token: plaintext, expiresAt })` with a `.catch(log)` — never block the response on notifier latency.
+5. Always reply `202 { delivered: true }`.
+
+Rate-limit cap covered by the existing `/api/auth/*` 10/min/IP global bucket.
+
+### 4. `POST /api/auth/password-reset/confirm`
+
+Pipeline:
+
+1. Body validated as `{ token, password }` with `password.minLength: 8` enforced at the schema layer (Fastify returns 422 `validation_failed` automatically).
+2. SHA-256 the supplied `token` → lookup `PasswordToken` by hash. Reject (401 `invalid_token`) if missing, expired (`expiresAt <= now`), or already used (`usedAt != null`).
+3. Look up the person. Reject same 401 if missing/deleted.
+4. Look up the existing `LegacyPasswordCredential`. Reject same 401 if absent — per spec, password-reset never *creates* a credential for a person who doesn't already have one.
+5. `rehashPassword(password)` → argon2id at current params.
+6. `putLegacyPassword({ ...existing, passwordHash: newHash, lastUsedAt: now })`.
+7. Mark `PasswordToken.usedAt = now` via `putPasswordToken`.
+8. Mint session with `loginMethod: 'password_reset'`, persist session metadata, set cookies, 200 `{ person }`.
+
+Every error path collapses to a uniform 401 with `error.code = "invalid_token"` so timing + response shape can't distinguish "unknown token" from "expired token" from "no credential."
+
+### 5. SPA
+
+- **`api.auth.passwordResetRequest(usernameOrEmail)`** + **`api.auth.passwordResetConfirm(token, password)`** added to `apps/web/src/lib/api.ts`.
+- **`LoginPlaceholder`** — "Forgot your password?" `` added below the legacy-password submit button.
+- **`PasswordResetRequest` (new, `/login/forgot`)** — single-input form. On submit: call the request endpoint, swap to a generic "If we have an account on file matching , we just sent a reset link" confirmation panel regardless of outcome (anti-enumeration is the server's job, but the SPA mirrors the contract).
+- **`PasswordResetConfirm` (new, `/login/reset?token=...`)** — two password fields. Validates match + minimum length client-side. On submit: call confirm, then `reload()` auth, then navigate to `/`. Error handling for 401 (`"this reset link is invalid or has expired"`), 422, 429.
+- **`App.tsx`** — wires `/login/forgot` + `/login/reset`.
+
+### 6. Tests
+
+- **`apps/api/tests/auth-password-reset.test.ts` (new)** — 13 cases: anti-enumeration (unknown user / no-cred / no-email all 202 silent), happy path (token persisted + correct expiry + 1-hour window), email resolution, four 401 paths on confirm (unknown / expired / used / no-credential), 422 for short password, end-to-end happy path (cookies + credential rotated to argon2id + token marked used + verifyLegacyPassword roundtrip), single-use enforcement, end-to-end with `/api/auth/login` post-reset.
+- **`apps/web/tests/PasswordReset.test.tsx` (new)** — 5 cases: request form disabled when empty, request shows generic confirmation after submit, confirm warns when token missing, confirm blocks mismatched passwords, confirm renders friendly invalid-token error on 401.
+
+## Validation
+
+- [x] `POST /api/auth/password-reset/request` returns 202 in all branches; persists `PasswordToken` only when target resolves with email + credential.
+- [x] `POST /api/auth/password-reset/confirm` rotates credential to argon2id, marks token used, mints session with `loginMethod: 'password_reset'`; uniform 401 on every rejection path.
+- [x] Single-use enforced: same token returns 401 on second submit.
+- [x] New password verifies through `verifyLegacyPassword`; the old password no longer does.
+- [x] SPA `/login/forgot` flow renders + submits + shows generic confirmation.
+- [x] SPA `/login/reset?token=...` flow validates client-side + handles 401/422/429.
+- [x] `npm run type-check && npm run lint` clean.
+- [x] 13 new API tests pass; 5 new web tests pass; full sweep validated separately.
+
+## Risks / unknowns
+
+- **Notifier latency hides delivery failures.** Fire-and-forget means a Resend outage leaves the user staring at "we sent a link" with no link arriving. Acceptable v1 — the spec calls out the always-202 contract — but worth a future ops follow-up: a periodic "tokens issued without delivery confirmation" log query.
+- **Token format in URL leaks via referer.** The token rides in the query string of `/login/reset?token=...`. If the user clicks any external link from that page before submitting, the referer header could leak it. Mitigations: the page is server-rendered with no external links, but a paranoid future change could swap to URL-fragment encoding + a tiny JS fish-it-out shim. Not v1 work.
+- **No per-email rate-limit on `/request`.** Global IP cap (10/min) is fine for casual abuse; a determined attacker on a botnet could probe many emails. Punted per "who cares" decision in the strategy thread — revisit only if abuse signals appear.
+- **Tokens accumulate on disk.** No cleanup of expired tokens. The store is in-memory + flushed-on-write; for civic-scale this is fine for years, but a future `cleanup-expired-password-tokens.ts` script would be cheap insurance.
+
+## Notes
+
+Shipped clean — all 13 API tests + 5 web tests pass on first full sweep. Type-check + lint green.
+
+Surprises:
+
+- **Test seed-via-disk doesn't work after app boot.** First pass of `mintToken` in the confirm tests wrote tokens directly to `password-tokens.jsonl`. The route then 401'd because the in-memory map (loaded at boot) didn't see the post-boot file writes. Switched to `app.store.private.putPasswordToken(...)` which updates both. Worth remembering: any test seeding *after* `buildTestApp` must go through the store API, not direct file writes — only pre-boot seeds work as JSONL appends.
+- **`getByLabelText(/new password/i)` matched two labels.** Both "New password" and "Confirm new password" matched the partial regex, throwing a "multiple elements found" error. Fixed with `^...$` anchors. A general lesson for forms with overlapping labels — RTL's default substring match needs explicit boundaries when label text is a prefix of another.
+- **Token plaintext stays out of every log path.** `LoggingNotifier.notifyPasswordReset` deliberately logs slug + email + expiresAt but never the token, even though the dev log is the easy debug surface. Trade-off: if the email send fails silently, the dev has to re-trigger the flow to get a working token rather than fishing one from the logs. Worth it — the rule "plaintext tokens only appear over the email channel" is one of those security properties that's easy to define and hard to claw back if violated.
+- **Token expiry check uses `<=` not `<`.** A token whose `expiresAt` equals `now` is treated as expired. Boundary chosen to avoid a 1ms race window where the schema timestamp says expired but the comparison says valid.
+
+## Follow-ups
+
+- **Phase D — link-github.** `POST /api/auth/link-github` + link-mode OAuth callback variant + `/account` banner + SPA flow. *Deferred to plan* — `plans/login-migration-impl-phase-d.md`.
+- **Expired-token cleanup.** A periodic prune of `password-tokens.jsonl` entries past expiry. *None* — tokens are tiny; revisit if disk pressure ever shows up.
+- **Per-target-email rate-limit on `/request`.** Future hardening if abuse signals appear. *None.*
+- **Token in URL fragment instead of query.** Defense against referer-leak. *None* — not a v1 concern given the page has no external links.