diff --git a/apps/api/package.json b/apps/api/package.json index 43bc097..4ea5243 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,6 +25,7 @@ "@fastify/static": "^9.1.3", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.6", + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", "gitsheets": "^1.0.3", @@ -34,6 +35,7 @@ }, "devDependencies": { "@faker-js/faker": "^10.4.0", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.8.0", "msw": "^2.14.6", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dd49e77..cbef04d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -35,6 +35,7 @@ import sessionMiddlewarePlugin from './auth/middleware.js'; import staticWebPlugin from './plugins/static-web.js'; import { healthRoutes } from './routes/health.js'; import { authRoutes } from './routes/auth.js'; +import { accountClaimRoutes } from './routes/account-claim.js'; import { projectRoutes } from './routes/projects.js'; import { peopleRoutes } from './routes/people.js'; import { tagRoutes } from './routes/tags.js'; @@ -141,6 +142,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise { + if (BCRYPT_PREFIXES.some((p) => hash.startsWith(p))) { + return bcrypt.compare(password, hash); + } + throw new UnknownHashFormatError(hash.slice(0, Math.min(4, hash.length))); +} diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index e906f45..3947743 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -26,6 +26,7 @@ import { HelpWantedWriteService } from '../services/help-wanted.write.js'; import { PersonWriteService } from '../services/person.write.js'; import { TagWriteService } from '../services/tag.write.js'; import { GitHubAccountService } from '../services/github-account.js'; +import { AccountClaimService } from '../services/account-claim.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; declare module 'fastify' { @@ -46,6 +47,7 @@ declare module 'fastify' { peopleWrite: PersonWriteService; tagsWrite: TagWriteService; githubAccount: GitHubAccountService; + accountClaim: AccountClaimService; }; /** Shared in-memory state — write routes call StateApply.apply against this. */ inMemoryState: InMemoryState; @@ -67,6 +69,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { fastify.decorate('fts', fts); fastify.decorate('notifier', notifier); + const githubAccount = new GitHubAccountService(state); fastify.decorate('services', { projects: new ProjectService(state, fts), people: new PersonService(state, fts), @@ -81,7 +84,8 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { helpWantedWrite: new HelpWantedWriteService(state), peopleWrite: new PersonWriteService(state, fastify.store.private), tagsWrite: new TagWriteService(state), - githubAccount: new GitHubAccountService(state), + githubAccount, + accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount), }); } diff --git a/apps/api/src/routes/account-claim.ts b/apps/api/src/routes/account-claim.ts new file mode 100644 index 0000000..0bf5d15 --- /dev/null +++ b/apps/api/src/routes/account-claim.ts @@ -0,0 +1,602 @@ +/** + * Account-claim routes per specs/api/account-claim.md. + * + * Endpoints: + * GET /api/account-claim/candidates — claim-pending + * POST /api/account-claim/confirm — claim-pending + * POST /api/account-claim/decline — claim-pending + * POST /api/account-claim/by-password — claim-pending + * POST /api/account-claim/request-staff-review — claim-pending + * GET /api/account-claim/legacy — user + * POST /api/account-claim/legacy/request — user + * GET /api/staff/account-claim/queue — staff + * POST /api/staff/account-claim/:requestId/approve — staff + * POST /api/staff/account-claim/:requestId/deny — staff + * + * Auth model: claim endpoints validate a `cfp_claim` JWT cookie (scope='claim'). + * The session middleware deliberately does NOT honor cfp_claim — these routes + * verify it inline so a stray cookie can never escalate to a session. + */ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { errors as JoseErrors } from 'jose'; +import { ok } from '../lib/response.js'; +import { + ApiNotFoundError, + ApiValidationError, + ConflictError, + ForbiddenError, + UnauthenticatedError, +} from '../lib/errors.js'; +import { requireAuth } from '../auth/guards.js'; +import { verifyClaimPending } from '../auth/jwt.js'; +import { + clearClaimCookie, + setSessionCookies, +} from '../auth/cookies.js'; +import { mintSessionFor } from '../auth/issue.js'; +import type { SessionMeta } from '../auth/session-metadata.js'; +import { buildTransactionOptions } from '../store/commit-meta.js'; + +const CLAIM_TOKEN_INVALID = 'claim_token_invalid'; + +function clientIp(request: FastifyRequest): string { + const forwarded = request.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return (forwarded.split(',')[0] ?? '').trim(); + } + return request.socket?.remoteAddress ?? 'unknown'; +} + +async function persistSession( + fastify: FastifyInstance, + request: FastifyRequest, + refreshJti: string, + personId: string, +): Promise { + const now = Date.now(); + const meta: SessionMeta = { + refreshJti, + personId, + userAgent: String(request.headers['user-agent'] ?? ''), + ipAddress: clientIp(request), + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString(), + }; + await fastify.sessionMetadata.add(meta, fastify.store.private); +} + +async function readClaim( + fastify: FastifyInstance, + request: FastifyRequest, +): Promise extends Promise ? T : never> { + const token = request.cookies['cfp_claim']; + if (!token) { + throw new UnauthenticatedError('Claim token missing', CLAIM_TOKEN_INVALID); + } + try { + return await verifyClaimPending(token, fastify.config.CFP_JWT_SIGNING_KEY); + } catch (err) { + if ( + err instanceof JoseErrors.JWTExpired || + err instanceof JoseErrors.JWTInvalid || + err instanceof JoseErrors.JWSInvalid || + err instanceof JoseErrors.JWSSignatureVerificationFailed + ) { + throw new UnauthenticatedError('Claim token invalid', CLAIM_TOKEN_INVALID); + } + throw err; + } +} + +async function finalizeAutoClaim( + fastify: FastifyInstance, + request: FastifyRequest, + reply: FastifyReply, + personId: string, + accountLevel: 'user' | 'staff' | 'administrator', +): Promise { + const minted = await mintSessionFor( + personId, + accountLevel, + fastify.config.CFP_JWT_SIGNING_KEY, + ); + setSessionCookies( + reply, + { access: minted.accessToken, refresh: minted.refreshToken }, + fastify.config.NODE_ENV, + ); + await persistSession(fastify, request, minted.refreshJti, personId); + clearClaimCookie(reply); +} + +export async function accountClaimRoutes(fastify: FastifyInstance): Promise { + // --------------------------------------------------------------------------- + // GET /api/account-claim/candidates + // --------------------------------------------------------------------------- + fastify.get( + '/api/account-claim/candidates', + { + schema: { + tags: ['account-claim'], + summary: 'List candidate legacy Persons for the current claim flow', + }, + }, + async (request) => { + const claims = await readClaim(fastify, request); + const payload = await fastify.services.accountClaim.buildCandidateSummaries(claims); + return ok(payload); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/confirm + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/confirm', + { + schema: { + tags: ['account-claim'], + summary: 'Confirm an email-match candidate and link the GitHub identity', + body: { + type: 'object', + required: ['personId'], + properties: { personId: { type: 'string', format: 'uuid' } }, + }, + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + const { personId } = request.body as { personId: string }; + + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'account-claim.confirm', + subjectType: 'person', + subjectId: personId, + responseCode: 200, + }), + async (tx) => fastify.services.accountClaim.confirm(tx, claims, personId), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.code === 'not_a_candidate') { + throw new ForbiddenError('Not a candidate for this claim', 'not_a_candidate'); + } + if (outcome.code === 'email_match_required') { + throw new ForbiddenError('Email match required', 'email_match_required'); + } + // already_claimed → 409 + throw new ConflictError('Candidate already claimed', 'already_claimed'); + } + + outcome.result.stateApply.apply(fastify.inMemoryState, fastify.fts); + + await finalizeAutoClaim( + fastify, + request, + reply, + outcome.result.person.id, + outcome.result.person.accountLevel, + ); + + return reply.code(200).send( + ok({ person: outcome.result.person, accountLevel: outcome.result.person.accountLevel }), + ); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/decline + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/decline', + { + schema: { + tags: ['account-claim'], + summary: 'Decline all candidates; create a fresh Person', + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + + const result = await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.decline', + subjectType: 'person', + responseCode: 201, + }), + writeOrder: 'private-first', + }, + async (tx) => fastify.services.accountClaim.decline(tx, claims), + ); + + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + + await finalizeAutoClaim( + fastify, + request, + reply, + result.value.person.id, + result.value.person.accountLevel, + ); + + return reply.code(201).send( + ok({ person: result.value.person, accountLevel: result.value.person.accountLevel }), + ); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/by-password + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/by-password', + { + schema: { + tags: ['account-claim'], + summary: 'Verify legacy slug + password to claim', + body: { + type: 'object', + required: ['slug', 'password'], + properties: { + slug: { type: 'string', minLength: 1 }, + password: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + const { slug, password } = request.body as { slug: string; password: string }; + + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'account-claim.by-password', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + }), + async (tx) => fastify.services.accountClaim.byPassword(tx, claims, slug, password), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.reason === 'unknown_format') { + // Internal log — uniform user-facing response per anti-enumeration. + request.log.warn( + { slug, reason: 'unknown_format' }, + 'legacy password hash format unknown', + ); + } + // Uniform 401 for any failure + throw new UnauthenticatedError( + 'Invalid credentials', + 'claim_credentials_invalid', + ); + } + + outcome.result.stateApply.apply(fastify.inMemoryState, fastify.fts); + + await finalizeAutoClaim( + fastify, + request, + reply, + outcome.result.person.id, + outcome.result.person.accountLevel, + ); + + return reply.code(200).send( + ok({ person: outcome.result.person, accountLevel: outcome.result.person.accountLevel }), + ); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/request-staff-review + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/request-staff-review', + { + schema: { + tags: ['account-claim'], + summary: 'Submit a free-form claim request for staff review', + body: { + type: 'object', + required: ['claimedSlug', 'evidence'], + properties: { + claimedSlug: { type: 'string', minLength: 1, maxLength: 100 }, + evidence: { type: 'string', minLength: 1, maxLength: 5000 }, + }, + }, + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + const { claimedSlug, evidence } = request.body as { + claimedSlug: string; + evidence: string; + }; + + await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.request-staff-review', + subjectType: 'account-claim-request', + // Note: claimedSlug intentionally omitted from trailers — slug + // existence shouldn't leak via the public commit log. + responseCode: 202, + }), + writeOrder: 'private-first', + }, + async (tx) => + fastify.services.accountClaim.requestStaffReview(tx, claims, claimedSlug, evidence), + ); + + return reply.code(202).send(ok({ delivered: true })); + }, + ); + + // --------------------------------------------------------------------------- + // GET /api/account-claim/legacy — post-onboarding search + // --------------------------------------------------------------------------- + fastify.get( + '/api/account-claim/legacy', + { + schema: { + tags: ['account-claim'], + summary: 'Post-onboarding: search for a legacy account to claim', + querystring: { + type: 'object', + required: ['q'], + properties: { q: { type: 'string', minLength: 1 } }, + }, + }, + }, + async (request) => { + requireAuth(request, ['user']); + const { q } = request.query as { q: string }; + const requesterId = request.session.personId ?? request.session.person!.id; + const candidate = await fastify.services.accountClaim.legacySearch(q, requesterId); + return ok({ candidates: candidate ? [candidate] : [] }); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/legacy/request — post-onboarding staff review + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/legacy/request', + { + schema: { + tags: ['account-claim'], + summary: 'Post-onboarding: submit a merge request to staff', + body: { + type: 'object', + required: ['claimedSlug', 'evidence'], + properties: { + claimedSlug: { type: 'string', minLength: 1, maxLength: 100 }, + evidence: { type: 'string', minLength: 1, maxLength: 5000 }, + }, + }, + }, + }, + async (request, reply) => { + requireAuth(request, ['user']); + const { claimedSlug, evidence } = request.body as { + claimedSlug: string; + evidence: string; + }; + const requester = request.session.person; + if (!requester) { + throw new UnauthenticatedError('Authentication required'); + } + if (!requester.githubUserId) { + throw new ApiValidationError( + 'Requester has no GitHub identity to link', + { githubUserId: 'missing' }, + ); + } + + await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.legacy-request', + subjectType: 'account-claim-request', + responseCode: 202, + }), + writeOrder: 'private-first', + }, + async (tx) => + fastify.services.accountClaim.legacyRequest( + tx, + requester, + requester.githubUserId!, + claimedSlug, + evidence, + ), + ); + + return reply.code(202).send(ok({ delivered: true })); + }, + ); + + // --------------------------------------------------------------------------- + // Staff queue endpoints + // --------------------------------------------------------------------------- + + fastify.get( + '/api/staff/account-claim/queue', + { + schema: { + tags: ['account-claim'], + summary: 'List open account-claim requests (staff only)', + }, + }, + async (request) => { + requireAuth(request, ['staff']); + const items = await fastify.services.accountClaim.staffQueue(); + return ok( + items.map((r) => ({ + requestId: r.id, + type: r.type, + claimedSlug: r.claimedSlug, + claimedPersonId: r.claimedPersonId, + requesterGithubLogin: r.requesterGithubLogin, + requesterPersonId: r.requesterPersonId, + evidence: r.evidence, + submittedAt: r.submittedAt, + })), + ); + }, + ); + + fastify.post( + '/api/staff/account-claim/:requestId/approve', + { + schema: { + tags: ['account-claim'], + summary: 'Approve a pending account-claim request (staff only)', + params: { + type: 'object', + required: ['requestId'], + properties: { requestId: { type: 'string', format: 'uuid' } }, + }, + body: { + type: 'object', + properties: { reason: { type: 'string', maxLength: 2000 } }, + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + requireAuth(request, ['staff']); + const { requestId } = request.params as { requestId: string }; + const body = (request.body ?? {}) as { reason?: string }; + const staffPersonId = request.session.personId ?? request.session.person!.id; + + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'account-claim.approve', + subjectType: 'account-claim-request', + subjectId: requestId, + responseCode: 200, + extraTrailers: body.reason ? { Reason: body.reason } : undefined, + }), + async (tx) => + fastify.services.accountClaim.staffApprove( + tx, + requestId, + staffPersonId, + body.reason ?? null, + ), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.code === 'not_found') { + throw new ApiNotFoundError(`Claim request ${requestId} not found`); + } + if (outcome.code === 'already_reviewed') { + throw new ConflictError('Claim request already reviewed', 'already_reviewed'); + } + if (outcome.code === 'no_claimed_person') { + throw new ApiValidationError( + 'No claimed Person resolves for this request', + { claimedSlug: 'unresolved' }, + ); + } + if (outcome.code === 'requester_missing') { + throw new ApiValidationError('Requester Person missing', { requesterPersonId: 'missing' }); + } + // already_claimed + throw new ConflictError( + 'Target Person already linked to a GitHub identity', + 'already_claimed', + ); + } + + outcome.result.stateApply.apply(fastify.inMemoryState, fastify.fts); + if (outcome.result.mergeApply) { + outcome.result.mergeApply.hardRemovePersonFromState(fastify.inMemoryState, fastify.fts); + } + + return reply.code(200).send( + ok({ + requestId: outcome.result.request.id, + status: outcome.result.request.status, + person: outcome.result.person, + }), + ); + }, + ); + + fastify.post( + '/api/staff/account-claim/:requestId/deny', + { + schema: { + tags: ['account-claim'], + summary: 'Deny a pending account-claim request (staff only)', + params: { + type: 'object', + required: ['requestId'], + properties: { requestId: { type: 'string', format: 'uuid' } }, + }, + body: { + type: 'object', + properties: { reason: { type: 'string', maxLength: 2000 } }, + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + requireAuth(request, ['staff']); + const { requestId } = request.params as { requestId: string }; + const body = (request.body ?? {}) as { reason?: string }; + const staffPersonId = request.session.personId ?? request.session.person!.id; + + const result = await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.deny', + subjectType: 'account-claim-request', + subjectId: requestId, + responseCode: 200, + extraTrailers: body.reason ? { Reason: body.reason } : undefined, + }), + writeOrder: 'private-first', + }, + async (tx) => + fastify.services.accountClaim.staffDeny( + tx, + requestId, + staffPersonId, + body.reason ?? null, + ), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.code === 'not_found') { + throw new ApiNotFoundError(`Claim request ${requestId} not found`); + } + throw new ConflictError('Claim request already reviewed', 'already_reviewed'); + } + + return reply.code(200).send( + ok({ + requestId: outcome.result.request.id, + status: outcome.result.request.status, + }), + ); + }, + ); +} diff --git a/apps/api/src/services/account-claim.ts b/apps/api/src/services/account-claim.ts new file mode 100644 index 0000000..151b603 --- /dev/null +++ b/apps/api/src/services/account-claim.ts @@ -0,0 +1,791 @@ +/** + * Account-claim service. + * + * The legacy-account claim flow per specs/api/account-claim.md and + * specs/behaviors/account-migration.md. Three identity proofs map to three + * write paths: + * + * A. Email-match → confirm(personId) — auto-claim + * B. Password → byPassword(slug, password) — auto-claim after verify + * C. Staff review → requestStaffReview(...) — queued for admin + * + * Plus the post-onboarding merge path, which always goes through staff. + * + * All write paths run inside `store.transact` so the public commit and + * private mutation land together; the dual-store atomicity guarantees from + * specs/behaviors/private-storage.md apply. + */ +import { + PersonSchema, + PrivateProfileSchema, + SlugHistorySchema, + type AccountClaimRequest, + type HelpWantedInterestExpression, + type HelpWantedRole, + type Person, + type PrivateProfile, + type ProjectBuzz, + type ProjectMembership, + type ProjectUpdate, + type SlugHistory, +} from '@cfp/shared/schemas'; +import { uuidv7 } from 'uuidv7'; +import type { DualStoreTx } from '../store/store.js'; +import type { InMemoryState } from '../store/memory/state.js'; +import { StateApply } from '../store/state-apply.js'; +import type { PrivateStore } from '../store/private/index.js'; +import type { + ClaimPendingClaims, + GhIdentitySnapshot, +} from '../auth/jwt.js'; +import { verifyLaddrPassword, UnknownHashFormatError } from '../auth/legacy-password.js'; +import { GitHubAccountService } from './github-account.js'; +import type { ResolvedGitHubIdentity } from '../auth/github-client.js'; + +const SLUG_HISTORY_TTL_DAYS = 90; + +function nowIso(): string { + return new Date().toISOString(); +} + +function expiresInDays(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +export interface CandidateSummary { + readonly personId: string; + readonly slug: string; + readonly fullName: string; + readonly memberOfCount: number; + readonly lastActiveAt: string; + readonly matchedVia: ReadonlyArray<'email' | 'username'>; + readonly matchedEmail: string | null; +} + +export interface CandidatesPayload { + readonly ghLogin: string; + readonly ghName: string | null; + readonly candidates: CandidateSummary[]; +} + +export interface ClaimSuccessResult { + readonly person: Person; + readonly stateApply: StateApply; +} + +/** Result of `confirm` and `byPassword` — used by the route to issue session. */ +export type AutoClaimResult = ClaimSuccessResult; + +/** Result of `decline` — fresh Person + PrivateProfile created. */ +export type DeclineResult = ClaimSuccessResult; + +export interface StaffApproveResult { + readonly request: AccountClaimRequest; + readonly person: Person | null; + readonly stateApply: StateApply; + /** Set only when the approval is a post-onboarding merge. */ + readonly mergeApply?: MergeApply; +} + +export interface StaffDenyResult { + readonly request: AccountClaimRequest; +} + +/** Reconstruct a ResolvedGitHubIdentity from the claim-pending JWT claims. */ +export function ghIdentityFromClaim(claims: ClaimPendingClaims): ResolvedGitHubIdentity { + const id = Number(claims.sub); + if (!Number.isFinite(id) || id <= 0) { + throw new Error('Invalid GitHub id in claim JWT'); + } + // The claim-pending JWT only carries email *strings*, not the full + // {primary,verified} tuples we get from /user/emails. By construction every + // email in the JWT was verified at OAuth time, so we reconstruct flags. + const emails = claims.ghEmails.map((email, idx) => ({ + email, + primary: idx === 0, + verified: true, + })); + return { + id, + login: claims.ghLogin, + name: claims.ghName, + emails, + primaryEmail: emails[0]?.email ?? null, + }; +} + +/** Reconstruct a GhIdentitySnapshot from the claim-pending JWT claims. */ +export function ghSnapshotFromClaim(claims: ClaimPendingClaims): GhIdentitySnapshot { + return { + ghId: claims.sub, + ghLogin: claims.ghLogin, + ghName: claims.ghName, + ghEmails: claims.ghEmails, + }; +} + +export class AccountClaimService { + readonly #state: InMemoryState; + readonly #privateStore: PrivateStore; + readonly #githubAccount: GitHubAccountService; + + constructor( + state: InMemoryState, + privateStore: PrivateStore, + githubAccount: GitHubAccountService, + ) { + this.#state = state; + this.#privateStore = privateStore; + this.#githubAccount = githubAccount; + } + + /** + * Build candidate summaries for the candidate IDs carried in a claim JWT. + * Skips any IDs that no longer resolve (deleted) or that have since been + * claimed by another GitHub identity (race). + */ + async buildCandidateSummaries(claims: ClaimPendingClaims): Promise { + const verifiedEmails = new Set(claims.ghEmails.map((e) => e.toLowerCase())); + const usernameSlug = claims.ghLogin.toLowerCase(); + const candidates: CandidateSummary[] = []; + + for (const personId of claims.candidates) { + const person = this.#state.people.get(personId); + if (!person || person.deletedAt || person.githubUserId) continue; + + const profile = await this.#privateStore.getProfile(personId); + const matchedEmail = + profile && verifiedEmails.has(profile.email.toLowerCase()) ? profile.email : null; + const usernameMatch = person.slug.toLowerCase() === usernameSlug; + const matchedVia: Array<'email' | 'username'> = []; + if (matchedEmail) matchedVia.push('email'); + if (usernameMatch) matchedVia.push('username'); + if (matchedVia.length === 0) continue; + + candidates.push({ + personId: person.id, + slug: person.slug, + fullName: person.fullName, + memberOfCount: this.#state.membershipsByPerson.get(person.id)?.size ?? 0, + lastActiveAt: person.updatedAt, + matchedVia, + matchedEmail, + }); + } + + return { + ghLogin: claims.ghLogin, + ghName: claims.ghName, + candidates, + }; + } + + /** + * Confirm an email-match candidate. The candidate must be in the JWT's + * candidate set AND have a verified GitHub email matching its PrivateProfile. + * Username-only matches throw `email_match_required` — those must use + * by-password or staff-review. + */ + async confirm( + tx: DualStoreTx, + claims: ClaimPendingClaims, + personId: string, + ): Promise< + | { ok: true; result: AutoClaimResult } + | { ok: false; code: 'not_a_candidate' | 'email_match_required' | 'already_claimed' } + > { + if (!claims.candidates.includes(personId)) { + return { ok: false, code: 'not_a_candidate' }; + } + const person = this.#state.people.get(personId); + if (!person || person.deletedAt) { + return { ok: false, code: 'not_a_candidate' }; + } + if (person.githubUserId) { + return { ok: false, code: 'already_claimed' }; + } + + const profile = await this.#privateStore.getProfile(personId); + const verifiedEmails = new Set(claims.ghEmails.map((e) => e.toLowerCase())); + if (!profile || !verifiedEmails.has(profile.email.toLowerCase())) { + return { ok: false, code: 'email_match_required' }; + } + + const identity = ghIdentityFromClaim(claims); + const primaryEmail = identity.primaryEmail ?? identity.emails[0]?.email ?? null; + if (!primaryEmail) { + // Should not happen — OAuth callback would have rejected this — but + // guard anyway so we never silently drop an email refresh. + return { ok: false, code: 'email_match_required' }; + } + + const claimed = await this.#linkLegacyPerson(tx, person, identity, primaryEmail, profile); + return { ok: true, result: claimed }; + } + + /** + * Decline all candidates: create a brand-new Person + PrivateProfile per the + * github-oauth "create-fresh" path. Legacy candidates remain available for + * someone else to claim. + */ + async decline(tx: DualStoreTx, claims: ClaimPendingClaims): Promise { + const identity = ghIdentityFromClaim(claims); + const primaryEmail = identity.primaryEmail ?? identity.emails[0]?.email; + if (!primaryEmail) { + throw new Error('Cannot decline without a verified GitHub email'); + } + const created = await this.#githubAccount.createFresh(tx, identity, primaryEmail); + return { person: created.person, stateApply: created.stateApply }; + } + + /** + * Verify legacy username + password and, on match, claim the candidate. + * + * Returns a uniform `invalid` for "no such slug," "already-claimed," + * "no credential on file," "wrong password," or unknown hash format + * (the last case is logged separately via the caller, since we still + * want a uniform user-visible response). + */ + async byPassword( + tx: DualStoreTx, + claims: ClaimPendingClaims, + slug: string, + password: string, + ): Promise< + | { ok: true; result: AutoClaimResult } + | { ok: false; code: 'invalid'; reason: 'no_slug' | 'already_claimed' | 'no_credential' | 'wrong_password' | 'unknown_format' } + > { + const personId = this.#state.personIdBySlug.get(slug.toLowerCase()); + if (!personId) { + return { ok: false, code: 'invalid', reason: 'no_slug' }; + } + const person = this.#state.people.get(personId); + if (!person || person.deletedAt) { + return { ok: false, code: 'invalid', reason: 'no_slug' }; + } + if (person.githubUserId) { + return { ok: false, code: 'invalid', reason: 'already_claimed' }; + } + + const cred = await this.#privateStore.getLegacyPassword(person.id); + if (!cred) { + return { ok: false, code: 'invalid', reason: 'no_credential' }; + } + + let matched: boolean; + try { + matched = await verifyLaddrPassword(password, cred.passwordHash); + } catch (err) { + if (err instanceof UnknownHashFormatError) { + return { ok: false, code: 'invalid', reason: 'unknown_format' }; + } + throw err; + } + if (!matched) { + return { ok: false, code: 'invalid', reason: 'wrong_password' }; + } + + const identity = ghIdentityFromClaim(claims); + const primaryEmail = identity.primaryEmail ?? identity.emails[0]?.email ?? null; + if (!primaryEmail) { + // Same guard as confirm() + return { ok: false, code: 'invalid', reason: 'wrong_password' }; + } + + const profile = await this.#privateStore.getProfile(person.id); + const claimed = await this.#linkLegacyPerson(tx, person, identity, primaryEmail, profile); + return { ok: true, result: claimed }; + } + + /** + * Create an open AccountClaimRequest. Always succeeds (anti-enumeration): + * the response is identical whether or not the claimed slug exists. + */ + async requestStaffReview( + tx: DualStoreTx, + claims: ClaimPendingClaims, + claimedSlug: string, + evidence: string, + ): Promise<{ request: AccountClaimRequest }> { + const personId = this.#state.personIdBySlug.get(claimedSlug.toLowerCase()) ?? null; + const request: AccountClaimRequest = { + id: uuidv7(), + type: 'pre-onboarding', + claimedPersonId: personId, + claimedSlug, + requesterGithubLogin: claims.ghLogin, + requesterGithubId: Number(claims.sub), + requesterPersonId: null, + evidence, + status: 'open', + submittedAt: nowIso(), + reviewedAt: null, + reviewedBy: null, + reviewedReason: null, + }; + tx.private.putClaimRequest(request); + return { request }; + } + + /** + * Post-onboarding search: signed-in user looks for their own legacy account. + * Anti-enumeration: returns 0 or 1 candidate; nothing reveals which slugs + * exist beyond what the user themselves typed. + */ + async legacySearch(q: string, requesterPersonId: string): Promise { + const trimmed = q.trim().toLowerCase(); + if (!trimmed) return null; + + let personId: string | null; + let matchedVia: Array<'email' | 'username'> = []; + let matchedEmail: string | null = null; + + if (trimmed.includes('@')) { + personId = await this.#privateStore.findPersonIdByEmail(trimmed); + if (personId) { + matchedVia = ['email']; + const p = await this.#privateStore.getProfile(personId); + matchedEmail = p?.email ?? null; + } + } else { + personId = this.#state.personIdBySlug.get(trimmed) ?? null; + if (personId) matchedVia = ['username']; + } + if (!personId) return null; + if (personId === requesterPersonId) return null; + + const person = this.#state.people.get(personId); + if (!person || person.deletedAt || person.githubUserId) return null; + + return { + personId: person.id, + slug: person.slug, + fullName: person.fullName, + memberOfCount: this.#state.membershipsByPerson.get(person.id)?.size ?? 0, + lastActiveAt: person.updatedAt, + matchedVia, + matchedEmail, + }; + } + + /** + * Post-onboarding staff-review submission. The requester is a signed-in user + * who realized later they had a legacy account. Approval triggers the merge. + */ + async legacyRequest( + tx: DualStoreTx, + requester: Person, + requesterGithubId: number, + claimedSlug: string, + evidence: string, + ): Promise<{ request: AccountClaimRequest }> { + const personId = this.#state.personIdBySlug.get(claimedSlug.toLowerCase()) ?? null; + const request: AccountClaimRequest = { + id: uuidv7(), + type: 'post-onboarding-merge', + claimedPersonId: personId, + claimedSlug, + requesterGithubLogin: requester.githubLogin ?? '', + requesterGithubId, + requesterPersonId: requester.id, + evidence, + status: 'open', + submittedAt: nowIso(), + reviewedAt: null, + reviewedBy: null, + reviewedReason: null, + }; + tx.private.putClaimRequest(request); + return { request }; + } + + /** Staff queue listing. Open requests only. */ + async staffQueue(): Promise { + const all = await this.#privateStore.listOpenClaimRequests(); + return all.slice().sort((a, b) => a.submittedAt.localeCompare(b.submittedAt)); + } + + /** Mark a request denied. */ + async staffDeny( + tx: DualStoreTx, + requestId: string, + staffPersonId: string, + reason: string | null, + ): Promise< + | { ok: true; result: StaffDenyResult } + | { ok: false; code: 'not_found' | 'already_reviewed' } + > { + const existing = await this.#privateStore.getClaimRequest(requestId); + if (!existing) return { ok: false, code: 'not_found' }; + if (existing.status !== 'open') return { ok: false, code: 'already_reviewed' }; + + const updated: AccountClaimRequest = { + ...existing, + status: 'denied', + reviewedAt: nowIso(), + reviewedBy: staffPersonId, + reviewedReason: reason, + }; + tx.private.putClaimRequest(updated); + return { ok: true, result: { request: updated } }; + } + + /** Approve a request — pre-onboarding link or post-onboarding merge. */ + async staffApprove( + tx: DualStoreTx, + requestId: string, + staffPersonId: string, + reason: string | null, + ): Promise< + | { ok: true; result: StaffApproveResult } + | { ok: false; code: 'not_found' | 'already_reviewed' | 'no_claimed_person' | 'requester_missing' | 'already_claimed' } + > { + const existing = await this.#privateStore.getClaimRequest(requestId); + if (!existing) return { ok: false, code: 'not_found' }; + if (existing.status !== 'open') return { ok: false, code: 'already_reviewed' }; + if (!existing.claimedPersonId) return { ok: false, code: 'no_claimed_person' }; + + const claimed = this.#state.people.get(existing.claimedPersonId); + if (!claimed || claimed.deletedAt) return { ok: false, code: 'no_claimed_person' }; + + const stateApply = new StateApply(); + const now = nowIso(); + + if (existing.type === 'pre-onboarding') { + // Link the requester's GitHub identity to the claimed legacy Person. + // The requester has no Person yet (pre-onboarding), so on their next + // sign-in the GitHub callback's byGithubUserId lookup will hit. + if (claimed.githubUserId) { + return { ok: false, code: 'already_claimed' }; + } + const updated = PersonSchema.parse({ + ...claimed, + githubUserId: existing.requesterGithubId, + githubLogin: existing.requesterGithubLogin, + githubLinkedAt: now, + slackSamlNameId: claimed.slackSamlNameId ?? claimed.slug, + updatedAt: now, + }); + await tx.public.people.upsert(updated); + stateApply.upsertPerson(updated); + // Drop the legacy credential — the user now signs in via GitHub. + tx.private.deleteLegacyPassword(claimed.id); + + const markedReviewed: AccountClaimRequest = { + ...existing, + status: 'approved', + reviewedAt: now, + reviewedBy: staffPersonId, + reviewedReason: reason, + }; + tx.private.putClaimRequest(markedReviewed); + return { ok: true, result: { request: markedReviewed, person: updated, stateApply } }; + } + + // post-onboarding-merge + if (!existing.requesterPersonId) return { ok: false, code: 'requester_missing' }; + const requester = this.#state.people.get(existing.requesterPersonId); + if (!requester || requester.deletedAt) return { ok: false, code: 'requester_missing' }; + if (claimed.githubUserId) return { ok: false, code: 'already_claimed' }; + + const mergeApply = await this.#mergePostOnboarding(tx, claimed, requester, now); + // Replay all merge ops onto our StateApply via its public API. + mergeApply.replay(stateApply); + + const markedReviewed: AccountClaimRequest = { + ...existing, + status: 'approved', + reviewedAt: now, + reviewedBy: staffPersonId, + reviewedReason: reason, + }; + tx.private.putClaimRequest(markedReviewed); + return { + ok: true, + result: { + request: markedReviewed, + person: mergeApply.updatedPerson, + stateApply, + mergeApply, + }, + }; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Link a GitHub identity to a legacy Person: + * - Person: set githubUserId/Login/LinkedAt, fix slackSamlNameId if absent + * - PrivateProfile: refresh email + emailRefreshedAt + * - LegacyPasswordCredential: delete (no longer needed) + */ + async #linkLegacyPerson( + tx: DualStoreTx, + person: Person, + identity: ResolvedGitHubIdentity, + primaryEmail: string, + currentProfile: PrivateProfile | null, + ): Promise { + const now = nowIso(); + const updated = PersonSchema.parse({ + ...person, + githubUserId: identity.id, + githubLogin: identity.login, + githubLinkedAt: now, + // Legacy import seeds slackSamlNameId from slug. If a legacy Person + // somehow lacks it, set it to the current slug so SAML continuity holds. + slackSamlNameId: person.slackSamlNameId ?? person.slug, + updatedAt: now, + }); + await tx.public.people.upsert(updated); + + const profile: PrivateProfile = PrivateProfileSchema.parse({ + personId: person.id, + email: primaryEmail.toLowerCase(), + emailRefreshedAt: now, + newsletter: currentProfile?.newsletter ?? null, + updatedAt: now, + }); + tx.private.putProfile(profile); + tx.private.deleteLegacyPassword(person.id); + + const stateApply = new StateApply().upsertPerson(updated); + return { person: updated, stateApply }; + } + + /** + * Merge a fresh requester Person into a legacy claimed Person. The legacy + * Person wins (per account-migration.md): all records authored by the + * requester are re-pointed to the legacy Person id, the requester Person is + * hard-deleted, the legacy Person gains the GitHub link, and a slug-history + * entry redirects the requester's slug for 90 days. + * + * Returns a structured payload the caller composes onto its StateApply. + */ + async #mergePostOnboarding( + tx: DualStoreTx, + claimed: Person, + requester: Person, + now: string, + ): Promise { + // Re-point authored records -------------------------------------------- + + const reMemberships: ProjectMembership[] = []; + const removedMemberships: ProjectMembership[] = []; + const requesterMembershipIds = this.#state.membershipsByPerson.get(requester.id) ?? new Set(); + for (const mId of requesterMembershipIds) { + const m = this.#state.projectMemberships.get(mId); + if (!m) continue; + const mPath = m as ProjectMembership & { projectSlug: string; personSlug: string }; + // If the claimed Person already has a membership in this project, + // drop the requester's duplicate rather than creating two. + const claimedMemberships = this.#state.membershipsByPerson.get(claimed.id) ?? new Set(); + const claimedHasIt = [...claimedMemberships].some((id) => { + const cm = this.#state.projectMemberships.get(id); + return cm?.projectId === m.projectId; + }); + if (claimedHasIt) { + await tx.public['project-memberships'].delete(mPath as unknown as ProjectMembership); + removedMemberships.push(m); + continue; + } + const updated: ProjectMembership = { + ...m, + personId: claimed.id, + personSlug: claimed.slug, + } as ProjectMembership; + // Path template uses {projectSlug}/{personSlug}, so renaming personSlug + // moves the file: delete the old key, then upsert the new one. + await tx.public['project-memberships'].delete(mPath as unknown as ProjectMembership); + await tx.public['project-memberships'].upsert(updated); + reMemberships.push(updated); + } + + const reUpdates: ProjectUpdate[] = []; + for (const u of this.#state.projectUpdates.values()) { + if (u.authorId !== requester.id) continue; + const updated: ProjectUpdate = { ...u, authorId: claimed.id }; + await tx.public['project-updates'].upsert(updated); + reUpdates.push(updated); + } + + const reBuzz: ProjectBuzz[] = []; + for (const b of this.#state.projectBuzz.values()) { + if (b.postedById !== requester.id) continue; + const updated: ProjectBuzz = { ...b, postedById: claimed.id }; + await tx.public['project-buzz'].upsert(updated); + reBuzz.push(updated); + } + + const reRoles: HelpWantedRole[] = []; + for (const r of this.#state.helpWantedRoles.values()) { + let updated: HelpWantedRole | null = null; + if (r.postedById === requester.id) { + updated = { ...(updated ?? r), postedById: claimed.id }; + } + if (r.filledById === requester.id) { + updated = { ...(updated ?? r), filledById: claimed.id }; + } + if (updated) { + await tx.public['help-wanted-roles'].upsert(updated); + reRoles.push(updated); + } + } + + const reInterest: HelpWantedInterestExpression[] = []; + const removedInterest: HelpWantedInterestExpression[] = []; + for (const e of this.#state.helpWantedInterest.values()) { + if (e.personId !== requester.id) continue; + const ePath = e as HelpWantedInterestExpression & { personSlug: string }; + // Dedupe: if the claimed Person already expressed interest in this role, + // drop the requester's row. + const claimedKey = `${e.roleId}:${claimed.id}`; + const claimedExisting = this.#state.interestByRoleAndPerson.get(claimedKey); + if (claimedExisting) { + await tx.public['help-wanted-interest'].delete(ePath as unknown as HelpWantedInterestExpression); + removedInterest.push(e); + continue; + } + const updated: HelpWantedInterestExpression = { + ...e, + personId: claimed.id, + personSlug: claimed.slug, + } as HelpWantedInterestExpression; + await tx.public['help-wanted-interest'].delete(ePath as unknown as HelpWantedInterestExpression); + await tx.public['help-wanted-interest'].upsert(updated); + reInterest.push(updated); + } + + // Update the claimed Person with GitHub identity ---------------------- + + const updatedClaimed = PersonSchema.parse({ + ...claimed, + githubUserId: requester.githubUserId ?? claimed.githubUserId ?? null, + githubLogin: requester.githubLogin ?? claimed.githubLogin ?? null, + githubLinkedAt: now, + slackSamlNameId: claimed.slackSamlNameId ?? claimed.slug, + updatedAt: now, + }); + await tx.public.people.upsert(updatedClaimed); + + // Slug history for requester's old slug ------------------------------- + + const slugHistory: SlugHistory = SlugHistorySchema.parse({ + id: uuidv7(), + entityType: 'person', + oldSlug: requester.slug, + newSlug: claimed.slug, + entityId: claimed.id, + changedAt: now, + expiresAt: expiresInDays(SLUG_HISTORY_TTL_DAYS), + }); + await tx.public['slug-history'].upsert(slugHistory); + + // Hard-delete the requester Person ----------------------------------- + + await tx.public.people.delete(requester); + + // Private side: refresh claimed profile email if requester had a fresher + // one, then delete requester's profile. + const claimedProfile = await this.#privateStore.getProfile(claimed.id); + const requesterProfile = await this.#privateStore.getProfile(requester.id); + if (requesterProfile) { + const merged: PrivateProfile = PrivateProfileSchema.parse({ + personId: claimed.id, + email: requesterProfile.email, + emailRefreshedAt: now, + newsletter: claimedProfile?.newsletter ?? requesterProfile.newsletter ?? null, + updatedAt: now, + }); + tx.private.putProfile(merged); + tx.private.deleteProfile(requester.id); + } + // No legacy password remains for the claimed Person at this point + // (post-onboarding flow means the legacy Person was unclaimed); delete + // anything still present defensively. + tx.private.deleteLegacyPassword(claimed.id); + + return new MergeApply({ + updatedPerson: updatedClaimed, + deletedRequesterPersonId: requester.id, + deletedRequesterSlug: requester.slug, + reMemberships, + removedMemberships, + reUpdates, + reBuzz, + reRoles, + reInterest, + removedInterest, + }); + } +} + +// --------------------------------------------------------------------------- +// MergeApply — sequenced in-memory updates queued for the post-onboarding merge +// --------------------------------------------------------------------------- + +interface MergeApplyInput { + readonly updatedPerson: Person; + readonly deletedRequesterPersonId: string; + readonly deletedRequesterSlug: string; + readonly reMemberships: ProjectMembership[]; + readonly removedMemberships: ProjectMembership[]; + readonly reUpdates: ProjectUpdate[]; + readonly reBuzz: ProjectBuzz[]; + readonly reRoles: HelpWantedRole[]; + readonly reInterest: HelpWantedInterestExpression[]; + readonly removedInterest: HelpWantedInterestExpression[]; +} + +export class MergeApply { + readonly updatedPerson: Person; + readonly deletedRequesterPersonId: string; + readonly ops: ReadonlyArray = []; + readonly #input: MergeApplyInput; + + constructor(input: MergeApplyInput) { + this.#input = input; + this.updatedPerson = input.updatedPerson; + this.deletedRequesterPersonId = input.deletedRequesterPersonId; + } + + /** + * Replay all merge mutations onto a StateApply via its public API. After + * `stateApply.apply()`, callers must also invoke `hardRemovePersonFromState` + * to clear the requester Person — StateApply doesn't expose a hard-remove + * for Person, and the merge specifically wants the requester id to disappear. + */ + replay(stateApply: StateApply): void { + const i = this.#input; + stateApply.upsertPerson(i.updatedPerson); + for (const m of i.reMemberships) stateApply.upsertMembership(m); + for (const m of i.removedMemberships) stateApply.removeMembership(m); + for (const u of i.reUpdates) stateApply.upsertProjectUpdate(u); + for (const b of i.reBuzz) stateApply.upsertProjectBuzz(b); + for (const r of i.reRoles) stateApply.upsertHelpWantedRole(r); + for (const e of i.reInterest) stateApply.upsertInterest(e); + } + + hardRemovePersonFromState(state: InMemoryState, fts: { removePerson: (slug: string) => void }): void { + const i = this.#input; + const oldId = i.deletedRequesterPersonId; + const oldSlug = i.deletedRequesterSlug; + state.people.delete(oldId); + state.personSlugById.delete(oldId); + state.personIdBySlug.delete(oldSlug); + state.membershipsByPerson.delete(oldId); + fts.removePerson(oldSlug); + // Clean up removed interest rows that were de-duped during merge. + for (const e of i.removedInterest) { + state.helpWantedInterest.delete(e.id); + state.interestByRole.get(e.roleId)?.delete(e.id); + const key = `${e.roleId}:${e.personId}`; + if (state.interestByRoleAndPerson.get(key) === e.id) { + state.interestByRoleAndPerson.delete(key); + } + } + } +} + diff --git a/apps/api/src/store/private/base.ts b/apps/api/src/store/private/base.ts index 08bec87..36e5bb9 100644 --- a/apps/api/src/store/private/base.ts +++ b/apps/api/src/store/private/base.ts @@ -1,5 +1,13 @@ -import { LegacyPasswordCredentialSchema, PrivateProfileSchema } from '@cfp/shared/schemas'; -import type { LegacyPasswordCredential, PrivateProfile } from '@cfp/shared/schemas'; +import { + AccountClaimRequestSchema, + LegacyPasswordCredentialSchema, + PrivateProfileSchema, +} from '@cfp/shared/schemas'; +import type { + AccountClaimRequest, + LegacyPasswordCredential, + PrivateProfile, +} from '@cfp/shared/schemas'; import type { PrivateIndices, PrivateStore, PrivateStoreTx } from './interface.js'; /** @@ -11,6 +19,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 claimRequests: Map = new Map(); readonly indices: PrivateIndices = { byEmail: new Map(), @@ -24,7 +33,11 @@ export abstract class BasePrivateStore implements PrivateStore { protected abstract writeRaw(key: string, content: string): Promise; async load(): Promise { - await Promise.all([this.loadProfiles(), this.loadLegacyPasswords()]); + await Promise.all([ + this.loadProfiles(), + this.loadLegacyPasswords(), + this.loadClaimRequests(), + ]); this.rebuildIndices(); } @@ -42,6 +55,11 @@ export abstract class BasePrivateStore implements PrivateStore { this.legacyPasswords; } + private async loadClaimRequests(): Promise { + const raw = await this.readRaw('account-claim-requests.jsonl'); + this.claimRequests = parseJsonl(raw, AccountClaimRequestSchema, 'id'); + } + private rebuildIndices(): void { this.indices.byEmail.clear(); this.indices.byUnsubscribeToken.clear(); @@ -95,6 +113,24 @@ export abstract class BasePrivateStore implements PrivateStore { return this.legacyPasswords.size; } + async getClaimRequest(requestId: string): Promise { + return this.claimRequests.get(requestId) ?? null; + } + + async putClaimRequest(req: AccountClaimRequest): Promise { + const parsed = AccountClaimRequestSchema.parse(req); + this.claimRequests.set(parsed.id, parsed); + await this.flushClaimRequests(); + } + + async listOpenClaimRequests(): Promise { + return [...this.claimRequests.values()].filter((r) => r.status === 'open'); + } + + async listAllClaimRequests(): Promise { + return [...this.claimRequests.values()]; + } + async readBlob(key: string): Promise { return this.readRaw(key); } @@ -107,11 +143,13 @@ export abstract class BasePrivateStore implements PrivateStore { // Snapshot current state so we can roll back if the handler throws const profilesSnapshot = new Map(this.profiles); const legacySnapshot = new Map(this.legacyPasswords); + const claimRequestsSnapshot = new Map(this.claimRequests); // Staged mutations applied only in-memory during the handler const stagedProfilePuts: Map = new Map(); const stagedProfileDeletes: Set = new Set(); const stagedLegacyDeletes: Set = new Set(); + const stagedClaimRequestPuts: Map = new Map(); const tx: PrivateStoreTx = { putProfile: (profile) => { @@ -126,6 +164,10 @@ export abstract class BasePrivateStore implements PrivateStore { deleteLegacyPassword: (personId) => { stagedLegacyDeletes.add(personId); }, + putClaimRequest: (req) => { + const parsed = AccountClaimRequestSchema.parse(req); + stagedClaimRequestPuts.set(parsed.id, parsed); + }, }; let result: T; @@ -135,6 +177,7 @@ export abstract class BasePrivateStore implements PrivateStore { // Handler threw: leave in-memory state unchanged this.profiles = profilesSnapshot; this.legacyPasswords = legacySnapshot; + this.claimRequests = claimRequestsSnapshot; this.rebuildIndices(); throw err; } @@ -149,6 +192,9 @@ export abstract class BasePrivateStore implements PrivateStore { for (const id of stagedLegacyDeletes) { this.legacyPasswords.delete(id); } + for (const [id, req] of stagedClaimRequestPuts) { + this.claimRequests.set(id, req); + } this.rebuildIndices(); // Flush to backend @@ -159,6 +205,9 @@ export abstract class BasePrivateStore implements PrivateStore { if (stagedLegacyDeletes.size > 0) { flushOps.push(this.flushLegacyPasswords()); } + if (stagedClaimRequestPuts.size > 0) { + flushOps.push(this.flushClaimRequests()); + } try { await Promise.all(flushOps); @@ -167,6 +216,7 @@ export abstract class BasePrivateStore implements PrivateStore { // rebuild indices. Log loudly; a reconciliation script will fix state. this.profiles = profilesSnapshot; this.legacyPasswords = legacySnapshot; + this.claimRequests = claimRequestsSnapshot; this.rebuildIndices(); throw new PrivateStoreError( 'private_store_unavailable', @@ -187,11 +237,17 @@ export abstract class BasePrivateStore implements PrivateStore { const lines = [...this.legacyPasswords.values()].map((p) => JSON.stringify(p)).join('\n'); await this.writeRaw('legacy-passwords.jsonl', lines ? lines + '\n' : ''); } + + protected async flushClaimRequests(): Promise { + const lines = [...this.claimRequests.values()].map((r) => JSON.stringify(r)).join('\n'); + await this.writeRaw('account-claim-requests.jsonl', lines ? lines + '\n' : ''); + } } function parseJsonl( raw: string | null, schema: { parse: (input: unknown) => T }, + keyField: 'personId' | 'id' = 'personId', ): Map { const map = new Map(); if (!raw) return map; @@ -199,9 +255,8 @@ function parseJsonl( const trimmed = line.trim(); if (!trimmed) continue; const record = schema.parse(JSON.parse(trimmed)); - // Both PrivateProfile and LegacyPasswordCredential are keyed by personId - const keyed = record as { personId: string }; - map.set(keyed.personId, record); + const keyed = record as Record; + map.set(keyed[keyField] as string, record); } return map; } diff --git a/apps/api/src/store/private/interface.ts b/apps/api/src/store/private/interface.ts index 1ffdca6..d0e4dac 100644 --- a/apps/api/src/store/private/interface.ts +++ b/apps/api/src/store/private/interface.ts @@ -1,4 +1,8 @@ -import type { LegacyPasswordCredential, PrivateProfile } from '@cfp/shared/schemas'; +import type { + AccountClaimRequest, + LegacyPasswordCredential, + PrivateProfile, +} from '@cfp/shared/schemas'; /** Secondary in-memory indices built from private store data. */ export interface PrivateIndices { @@ -19,6 +23,7 @@ export interface PrivateStoreTx { putProfile(profile: PrivateProfile): void; deleteProfile(personId: string): void; deleteLegacyPassword(personId: string): void; + putClaimRequest(req: AccountClaimRequest): void; } /** @@ -47,6 +52,12 @@ export interface PrivateStore { deleteLegacyPassword(personId: string): Promise; countLegacyPasswords(): Promise; + // --- Account-claim requests --- + getClaimRequest(requestId: string): Promise; + putClaimRequest(req: AccountClaimRequest): Promise; + listOpenClaimRequests(): Promise; + listAllClaimRequests(): Promise; + /** * Run a handler with a transaction object. On success, flush updated * `.jsonl` files to the backend. On throw, discard; in-memory state diff --git a/apps/api/src/store/store.ts b/apps/api/src/store/store.ts index 7a11740..98c96f5 100644 --- a/apps/api/src/store/store.ts +++ b/apps/api/src/store/store.ts @@ -1,5 +1,5 @@ import type { TransactionOptions, TransactionResult } from 'gitsheets'; -import type { PrivateProfile } from '@cfp/shared/schemas'; +import type { AccountClaimRequest, PrivateProfile } from '@cfp/shared/schemas'; import type { PrivateStore, PrivateStoreTx } from './private/index.js'; import type { PublicStore, PublicStoreTx } from './public.js'; @@ -77,17 +77,20 @@ export class Store { const stagedPrivatePuts: PrivateProfile[] = []; const stagedPrivateProfileDeletes: string[] = []; const stagedLegacyPasswordDeletes: string[] = []; + const stagedClaimRequestPuts: AccountClaimRequest[] = []; const privateTx: PrivateStoreTx = { putProfile: (profile) => { stagedPrivatePuts.push(profile); }, deleteProfile: (personId) => { stagedPrivateProfileDeletes.push(personId); }, deleteLegacyPassword: (personId) => { stagedLegacyPasswordDeletes.push(personId); }, + putClaimRequest: (req) => { stagedClaimRequestPuts.push(req); }, }; const hasPrivateMutations = () => stagedPrivatePuts.length > 0 || stagedPrivateProfileDeletes.length > 0 || - stagedLegacyPasswordDeletes.length > 0; + stagedLegacyPasswordDeletes.length > 0 || + stagedClaimRequestPuts.length > 0; const flushPrivate = async (): Promise => { if (!hasPrivateMutations()) return; @@ -95,6 +98,7 @@ export class Store { for (const profile of stagedPrivatePuts) tx.putProfile(profile); for (const id of stagedPrivateProfileDeletes) tx.deleteProfile(id); for (const id of stagedLegacyPasswordDeletes) tx.deleteLegacyPassword(id); + for (const req of stagedClaimRequestPuts) tx.putClaimRequest(req); }); }; diff --git a/apps/api/tests/account-claim.test.ts b/apps/api/tests/account-claim.test.ts new file mode 100644 index 0000000..43d8ce8 --- /dev/null +++ b/apps/api/tests/account-claim.test.ts @@ -0,0 +1,756 @@ +/** + * Tests for the account-claim plan validation criteria. + * + * Covers each endpoint per specs/api/account-claim.md: + * - GET /candidates + * - POST /confirm (email-match auto-claim) + * - POST /decline (fresh Person) + * - POST /by-password (legacy bcrypt verify) + * - POST /request-staff-review (anti-enumeration: 202 always) + * - GET /legacy (post-onboarding search) + * - POST /legacy/request (post-onboarding submission) + * - GET /staff/.../queue + * - POST /staff/.../:id/approve (pre-onboarding + post-onboarding merge) + * - POST /staff/.../:id/deny + * + * Most tests bypass the GitHub OAuth callback and mint a `cfp_claim` JWT + * directly so each scenario can seed its own candidate state. Each test uses + * a unique remoteAddress so the 10-req/min/IP cap doesn't cross-contaminate. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type FastifyInstance } from 'fastify'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { writeFile, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import bcrypt from 'bcryptjs'; + +import { buildApp } from '../src/app.js'; +import { issueClaimPending, issueSession } from '../src/auth/jwt.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; + +const exec = promisify(execFile); +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; + +let testIpCounter = 0; +function nextTestIp(): string { + testIpCounter += 1; + return `10.1.${Math.floor(testIpCounter / 250)}.${testIpCounter % 250}`; +} + +interface SeedPersonOpts { + readonly accountLevel?: 'user' | 'staff' | 'administrator'; + readonly githubUserId?: number; + readonly githubLogin?: string; + readonly githubLinkedAt?: string; + readonly slackSamlNameId?: string; +} + +async function seedPerson( + repoDir: string, + slug: string, + id: string, + opts: SeedPersonOpts = {}, +): Promise { + const git = (...args: string[]) => exec('git', args, { cwd: repoDir }); + const lines = [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${opts.accountLevel ?? 'user'}"`, + `createdAt = "2026-05-01T00:00:00Z"`, + `updatedAt = "2026-05-01T00:00:00Z"`, + ]; + if (opts.githubUserId !== undefined) lines.push(`githubUserId = ${opts.githubUserId}`); + if (opts.githubLogin !== undefined) lines.push(`githubLogin = "${opts.githubLogin}"`); + if (opts.githubLinkedAt !== undefined) lines.push(`githubLinkedAt = "${opts.githubLinkedAt}"`); + if (opts.slackSamlNameId !== undefined) { + lines.push(`slackSamlNameId = "${opts.slackSamlNameId}"`); + } + + await mkdir(join(repoDir, 'people'), { recursive: true }); + await writeFile(join(repoDir, 'people', `${slug}.toml`), lines.join('\n')); + await git('add', `people/${slug}.toml`); + await git( + '-c', 'user.email=test@cfp.test', + '-c', 'user.name=test', + 'commit', '-m', `seed person ${slug}`, + ); +} + +async function seedPrivateProfile( + privatePath: string, + personId: string, + email: string, +): Promise { + const filePath = join(privatePath, 'profiles.jsonl'); + const profile = { + personId, + email: email.toLowerCase(), + emailRefreshedAt: '2026-05-01T00:00:00Z', + newsletter: null, + updatedAt: '2026-05-01T00:00:00Z', + }; + let content = ''; + try { + content = await readFile(filePath, 'utf8'); + } catch { + // file doesn't exist yet + } + await writeFile(filePath, content + JSON.stringify(profile) + '\n'); +} + +async function seedLegacyPassword( + privatePath: string, + personId: string, + passwordHash: string, +): Promise { + const filePath = join(privatePath, 'legacy-passwords.jsonl'); + const cred = { + personId, + passwordHash, + importedAt: '2026-05-01T00:00:00Z', + }; + let content = ''; + try { + content = await readFile(filePath, 'utf8'); + } catch { + // first write + } + await writeFile(filePath, content + JSON.stringify(cred) + '\n'); +} + +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, + GITHUB_OAUTH_CLIENT_ID: 'test-client-id', + GITHUB_OAUTH_CLIENT_SECRET: 'test-client-secret', + NODE_ENV: 'test', + }, + }); +} + +async function mintClaim( + ghId: string, + ghLogin: string, + ghEmails: string[], + candidates: string[], +): Promise { + return issueClaimPending( + { ghId, ghLogin, ghName: ghLogin, ghEmails }, + candidates, + JWT_KEY, + ); +} + +// --------------------------------------------------------------------------- +// GET /api/account-claim/candidates +// --------------------------------------------------------------------------- + +describe('GET /api/account-claim/candidates', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const candidateId = '01951a3c-0000-7000-8000-0000aaaaaaa1'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'jane-doe', candidateId); + await seedPrivateProfile(privateStore.path, candidateId, 'jane@example.com'); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('returns 401 claim_token_invalid when cfp_claim cookie missing', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/account-claim/candidates', + remoteAddress: nextTestIp(), + }); + expect(res.statusCode).toBe(401); + const body = res.json() as { error: { code: string } }; + expect(body.error.code).toBe('claim_token_invalid'); + }); + + it('returns candidates with matchedVia and matchedEmail for email match', async () => { + const token = await mintClaim('77', 'jane', ['jane@example.com'], [candidateId]); + const res = await app.inject({ + method: 'GET', + url: '/api/account-claim/candidates', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { + data: { + ghLogin: string; + candidates: Array<{ personId: string; matchedVia: string[]; matchedEmail: string | null }>; + }; + }; + expect(body.data.ghLogin).toBe('jane'); + expect(body.data.candidates).toHaveLength(1); + expect(body.data.candidates[0]!.personId).toBe(candidateId); + expect(body.data.candidates[0]!.matchedVia).toEqual(['email']); + expect(body.data.candidates[0]!.matchedEmail).toBe('jane@example.com'); + }); + + it('marks a username-only candidate with matchedVia=["username"] and null email', async () => { + const token = await mintClaim('78', 'jane-doe', ['someoneelse@example.com'], [candidateId]); + const res = await app.inject({ + method: 'GET', + url: '/api/account-claim/candidates', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { + data: { candidates: Array<{ matchedVia: string[]; matchedEmail: string | null }> }; + }; + expect(body.data.candidates[0]!.matchedVia).toEqual(['username']); + expect(body.data.candidates[0]!.matchedEmail).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/account-claim/confirm +// --------------------------------------------------------------------------- + +describe('POST /api/account-claim/confirm', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const candidateId = '01951a3c-0000-7000-8000-0000bbbbbbb1'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'confirm-target', candidateId); + await seedPrivateProfile(privateStore.path, candidateId, 'confirm@example.com'); + // Seed a legacy credential so we can assert it's removed on success + await seedLegacyPassword(privateStore.path, candidateId, '$2a$10$abcdefghijklmnopqrstuvwx'); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('links the Person, deletes legacy credential, issues session', async () => { + const token = await mintClaim('1234', 'gh-user', ['confirm@example.com'], [candidateId]); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/confirm', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: { personId: candidateId }, + }); + expect(res.statusCode).toBe(200); + + const setCookies = res.headers['set-cookie']; + const cookies = Array.isArray(setCookies) ? setCookies : [String(setCookies ?? '')]; + expect(cookies.some((c) => c.startsWith('cfp_session='))).toBe(true); + expect(cookies.some((c) => c.startsWith('cfp_refresh='))).toBe(true); + expect(cookies.some((c) => c.startsWith('cfp_claim=;') || c.startsWith('cfp_claim=;'))).toBe(true); + + const person = app.inMemoryState.people.get(candidateId); + expect(person?.githubUserId).toBe(1234); + expect(person?.githubLogin).toBe('gh-user'); + expect(person?.githubLinkedAt).toBeDefined(); + expect(person?.slackSamlNameId).toBe('confirm-target'); + + // Legacy credential deleted + const cred = await app.store.private.getLegacyPassword(candidateId); + expect(cred).toBeNull(); + + // Profile email refreshed + const profile = await app.store.private.getProfile(candidateId); + expect(profile?.email).toBe('confirm@example.com'); + }); + + it('refuses confirm when the personId is not in the JWT candidates', async () => { + const otherId = '01951a3c-0000-7000-8000-0000bbbbbbb9'; + const token = await mintClaim('99', 'someone-else', ['someone@example.com'], [otherId]); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/confirm', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: { personId: candidateId }, + }); + expect(res.statusCode).toBe(403); + const body = res.json() as { error: { code: string } }; + expect(body.error.code).toBe('not_a_candidate'); + }); + + it('refuses confirm with email_match_required when only username matches', async () => { + // Seed a separate candidate with no email overlap + const userId = '01951a3c-0000-7000-8000-0000bbbbbbb5'; + await seedPerson(dataRepo.path, 'username-only', userId); + await seedPrivateProfile(privateStore.path, userId, 'unrelated@example.com'); + // Reload the app state to pick up the new seed (simplest way: rebuild app) + await app.close(); + app = await buildTestApp(dataRepo.path, privateStore.path); + + const token = await mintClaim('1500', 'username-only', ['gh@example.com'], [userId]); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/confirm', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: { personId: userId }, + }); + expect(res.statusCode).toBe(403); + const body = res.json() as { error: { code: string } }; + expect(body.error.code).toBe('email_match_required'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/account-claim/decline +// --------------------------------------------------------------------------- + +describe('POST /api/account-claim/decline', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const candidateId = '01951a3c-0000-7000-8000-0000ccccccc1'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'declined-candidate', candidateId); + await seedPrivateProfile(privateStore.path, candidateId, 'never-claim@example.com'); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('creates a fresh Person and leaves the candidate untouched', async () => { + const token = await mintClaim('2001', 'brand-new-gh', ['fresh@example.com'], [candidateId]); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/decline', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: {}, + }); + expect(res.statusCode).toBe(201); + + const fresh = [...app.inMemoryState.people.values()].find( + (p) => p.githubUserId === 2001, + ); + expect(fresh).toBeDefined(); + expect(fresh?.slug).toBe('brand-new-gh'); + + // The candidate is still unclaimed + const candidate = app.inMemoryState.people.get(candidateId); + expect(candidate?.githubUserId).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/account-claim/by-password +// --------------------------------------------------------------------------- + +describe('POST /api/account-claim/by-password', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const candidateId = '01951a3c-0000-7000-8000-0000ddddddd1'; + const linkedId = '01951a3c-0000-7000-8000-0000ddddddd2'; + const correctPassword = 'hunter2-correct'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + // Unclaimed candidate with a bcrypt hash on file + await seedPerson(dataRepo.path, 'bcrypt-user', candidateId); + await seedPrivateProfile(privateStore.path, candidateId, 'bcrypt-user@example.com'); + const hash = await bcrypt.hash(correctPassword, 4); + await seedLegacyPassword(privateStore.path, candidateId, hash); + // Already-linked person — its password row should NOT be acceptable + await seedPerson(dataRepo.path, 'already-linked', linkedId, { + githubUserId: 5555, + githubLogin: 'linked-gh', + githubLinkedAt: '2026-04-01T00:00:00Z', + }); + await seedPrivateProfile(privateStore.path, linkedId, 'linked@example.com'); + await seedLegacyPassword(privateStore.path, linkedId, hash); + + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('claims on correct password', async () => { + const token = await mintClaim('3001', 'gh-bcrypt-user', ['gh-fresh@example.com'], []); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/by-password', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: { slug: 'bcrypt-user', password: correctPassword }, + }); + expect(res.statusCode).toBe(200); + + const person = app.inMemoryState.people.get(candidateId); + expect(person?.githubUserId).toBe(3001); + + const cred = await app.store.private.getLegacyPassword(candidateId); + expect(cred).toBeNull(); + }); + + it('returns uniform 401 claim_credentials_invalid for unknown slug', async () => { + const token = await mintClaim('3002', 'gh-nothing-user', ['none@example.com'], []); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/by-password', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: { slug: 'no-such-slug', password: 'whatever' }, + }); + expect(res.statusCode).toBe(401); + expect((res.json() as { error: { code: string } }).error.code).toBe( + 'claim_credentials_invalid', + ); + }); + + it('returns uniform 401 for an already-claimed slug (no enumeration)', async () => { + const token = await mintClaim('3003', 'gh-attempt', ['attempt@example.com'], []); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/by-password', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: { slug: 'already-linked', password: correctPassword }, + }); + expect(res.statusCode).toBe(401); + expect((res.json() as { error: { code: string } }).error.code).toBe( + 'claim_credentials_invalid', + ); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/account-claim/request-staff-review — anti-enumeration +// --------------------------------------------------------------------------- + +describe('POST /api/account-claim/request-staff-review', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('returns 202 even for a nonexistent slug (anti-enumeration)', async () => { + const token = await mintClaim('4001', 'gh-staff-test', ['s@example.com'], []); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/request-staff-review', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: token }, + payload: { claimedSlug: 'no-such-account', evidence: 'I really am them.' }, + }); + expect(res.statusCode).toBe(202); + const body = res.json() as { data: { delivered: boolean } }; + expect(body.data.delivered).toBe(true); + + const open = await app.store.private.listOpenClaimRequests(); + expect(open).toHaveLength(1); + expect(open[0]!.claimedPersonId).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Staff queue + approve/deny +// --------------------------------------------------------------------------- + +describe('Staff queue + approve/deny (pre-onboarding path)', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const candidateId = '01951a3c-0000-7000-8000-0000eeeeeee1'; + const staffId = '01951a3c-0000-7000-8000-0000eeeeeee2'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + await seedPerson(dataRepo.path, 'queue-target', candidateId); + await seedPrivateProfile(privateStore.path, candidateId, 'queue@example.com'); + await seedPerson(dataRepo.path, 'staff-user', staffId, { + accountLevel: 'staff', + githubUserId: 999, + githubLogin: 'staff-user', + githubLinkedAt: '2026-04-01T00:00:00Z', + }); + await seedPrivateProfile(privateStore.path, staffId, 'staff@example.com'); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + async function staffCookies(): Promise { + const { access } = await issueSession(staffId, 'staff', JWT_KEY); + return access; + } + + it('full lifecycle: submit → queue → approve links GH to legacy Person', async () => { + // 1. Submit a request via the claim flow + const claimToken = await mintClaim('7001', 'wants-queue', ['wq@example.com'], []); + const submitRes = await app.inject({ + method: 'POST', + url: '/api/account-claim/request-staff-review', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: claimToken }, + payload: { + claimedSlug: 'queue-target', + evidence: 'I am them. Email me at wq@example.com.', + }, + }); + expect(submitRes.statusCode).toBe(202); + + // 2. Staff lists the queue + const access = await staffCookies(); + const queueRes = await app.inject({ + method: 'GET', + url: '/api/staff/account-claim/queue', + remoteAddress: nextTestIp(), + cookies: { cfp_session: access }, + }); + expect(queueRes.statusCode).toBe(200); + const queueBody = queueRes.json() as { data: Array<{ requestId: string; claimedSlug: string }> }; + const open = queueBody.data.find((r) => r.claimedSlug === 'queue-target'); + expect(open).toBeDefined(); + const requestId = open!.requestId; + + // 3. Staff approves → legacy Person gets GH identity + const approveRes = await app.inject({ + method: 'POST', + url: `/api/staff/account-claim/${requestId}/approve`, + remoteAddress: nextTestIp(), + cookies: { cfp_session: access }, + payload: { reason: 'verified via Slack DM' }, + }); + expect(approveRes.statusCode).toBe(200); + + const candidate = app.inMemoryState.people.get(candidateId); + expect(candidate?.githubUserId).toBe(7001); + expect(candidate?.githubLogin).toBe('wants-queue'); + expect(candidate?.githubLinkedAt).toBeDefined(); + + // Request marked approved + const stored = await app.store.private.getClaimRequest(requestId); + expect(stored?.status).toBe('approved'); + expect(stored?.reviewedBy).toBe(staffId); + }); +}); + +// --------------------------------------------------------------------------- +// Post-onboarding /api/account-claim/legacy + merge approval +// --------------------------------------------------------------------------- + +describe('Post-onboarding /account-claim/legacy search + merge approval', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + const legacyId = '01951a3c-0000-7000-8000-0000fffffff1'; + const freshId = '01951a3c-0000-7000-8000-0000fffffff2'; + const staffId = '01951a3c-0000-7000-8000-0000fffffff3'; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + // Unclaimed legacy person + await seedPerson(dataRepo.path, 'legacy-old', legacyId); + await seedPrivateProfile(privateStore.path, legacyId, 'legacy-old@example.com'); + // Fresh post-onboarding person (already linked to GH 8001) + await seedPerson(dataRepo.path, 'fresh-new', freshId, { + githubUserId: 8001, + githubLogin: 'fresh-new', + githubLinkedAt: '2026-05-15T00:00:00Z', + }); + await seedPrivateProfile(privateStore.path, freshId, 'fresh-new@example.com'); + // Staff + await seedPerson(dataRepo.path, 'staff2', staffId, { + accountLevel: 'staff', + githubUserId: 9001, + githubLogin: 'staff2', + githubLinkedAt: '2026-04-01T00:00:00Z', + }); + await seedPrivateProfile(privateStore.path, staffId, 'staff2@example.com'); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('legacy search by username returns the legacy candidate', async () => { + const { access } = await issueSession(freshId, 'user', JWT_KEY); + const res = await app.inject({ + method: 'GET', + url: '/api/account-claim/legacy?q=legacy-old', + remoteAddress: nextTestIp(), + cookies: { cfp_session: access }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { data: { candidates: Array<{ personId: string }> } }; + expect(body.data.candidates).toHaveLength(1); + expect(body.data.candidates[0]!.personId).toBe(legacyId); + }); + + it('legacy search returns empty array for nonexistent slug (no enumeration)', async () => { + const { access } = await issueSession(freshId, 'user', JWT_KEY); + const res = await app.inject({ + method: 'GET', + url: '/api/account-claim/legacy?q=no-such-slug-here', + remoteAddress: nextTestIp(), + cookies: { cfp_session: access }, + }); + expect(res.statusCode).toBe(200); + const body = res.json() as { data: { candidates: unknown[] } }; + expect(body.data.candidates).toHaveLength(0); + }); + + it('submit + staff-approve merges fresh Person into legacy', async () => { + const { access: freshAccess } = await issueSession(freshId, 'user', JWT_KEY); + const submitRes = await app.inject({ + method: 'POST', + url: '/api/account-claim/legacy/request', + remoteAddress: nextTestIp(), + cookies: { cfp_session: freshAccess }, + payload: { + claimedSlug: 'legacy-old', + evidence: 'I had both — same person. legacy-old@example.com is my old address.', + }, + }); + expect(submitRes.statusCode).toBe(202); + + const { access: staffAccess } = await issueSession(staffId, 'staff', JWT_KEY); + const queueRes = await app.inject({ + method: 'GET', + url: '/api/staff/account-claim/queue', + remoteAddress: nextTestIp(), + cookies: { cfp_session: staffAccess }, + }); + const queueBody = queueRes.json() as { + data: Array<{ requestId: string; type: string; claimedSlug: string }>; + }; + const open = queueBody.data.find( + (r) => r.type === 'post-onboarding-merge' && r.claimedSlug === 'legacy-old', + ); + expect(open).toBeDefined(); + const requestId = open!.requestId; + + const approveRes = await app.inject({ + method: 'POST', + url: `/api/staff/account-claim/${requestId}/approve`, + remoteAddress: nextTestIp(), + cookies: { cfp_session: staffAccess }, + payload: { reason: 'merge approved' }, + }); + expect(approveRes.statusCode).toBe(200); + + // Legacy Person now has the GH identity + const legacy = app.inMemoryState.people.get(legacyId); + expect(legacy?.githubUserId).toBe(8001); + expect(legacy?.githubLogin).toBe('fresh-new'); + + // Fresh Person is hard-deleted + const fresh = app.inMemoryState.people.get(freshId); + expect(fresh).toBeUndefined(); + + // slug-history entry created for old → new + const showRes = await exec('git', ['show', 'HEAD:slug-history/person/fresh-new.toml'], { + cwd: dataRepo.path, + }); + expect(showRes.stdout).toContain('newSlug'); + expect(showRes.stdout).toContain('legacy-old'); + }); +}); + +// --------------------------------------------------------------------------- +// Commit-message PII smoke — verify no email/evidence leaks via trailers +// --------------------------------------------------------------------------- + +describe('Anti-PII commit trailers', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + app = await buildTestApp(dataRepo.path, privateStore.path); + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + it('request-staff-review trailer carries no slug, evidence, or email', async () => { + const claimToken = await mintClaim('9101', 'pii-test', ['pii-test@example.com'], []); + const res = await app.inject({ + method: 'POST', + url: '/api/account-claim/request-staff-review', + remoteAddress: nextTestIp(), + cookies: { cfp_claim: claimToken }, + payload: { + claimedSlug: 'secret-slug-do-not-leak', + evidence: 'my secret email: pii-test@private-domain.example', + }, + }); + expect(res.statusCode).toBe(202); + + // The staff-review submit only writes to the private store, so no public + // commit is produced. Verify by listing public log entries — the most + // recent commits should NOT mention the evidence. + const log = await exec('git', ['log', '--all', '--format=%B%n---END---'], { + cwd: dataRepo.path, + }); + expect(log.stdout).not.toContain('secret-slug-do-not-leak'); + expect(log.stdout).not.toContain('pii-test@private-domain.example'); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ec7cd20..d19a3e3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -24,7 +24,11 @@ import { Sponsor } from '@/screens/Sponsor'; import { ComingSoon } from '@/pages/ComingSoon'; import { NotFound } from '@/pages/NotFound'; import { LoginPlaceholder } from '@/pages/LoginPlaceholder'; -import { AccountClaimPlaceholder } from '@/pages/AccountClaimPlaceholder'; +import { AccountClaim } from '@/pages/AccountClaim'; +import { AccountClaimByPassword } from '@/pages/AccountClaimByPassword'; +import { AccountClaimRequestStaffReview } from '@/pages/AccountClaimRequestStaffReview'; +import { AccountClaimLegacy } from '@/pages/AccountClaimLegacy'; +import { StaffAccountClaimQueue } from '@/pages/StaffAccountClaimQueue'; const router = createBrowserRouter([ { @@ -55,7 +59,11 @@ const router = createBrowserRouter([ { path: '/pages/:slug', element: }, { path: '/contact', element: }, { path: '/login', element: }, - { path: '/account-claim', element: }, + { path: '/account-claim', element: }, + { path: '/account-claim/by-password', element: }, + { path: '/account-claim/request-staff-review', element: }, + { path: '/account/claim-legacy', element: }, + { path: '/staff/account-claim', element: }, { path: '*', element: }, ], }, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2a17d00..28dc34e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -488,6 +488,44 @@ export interface NewsletterResponse { newsletter: NewsletterState | null; } +export interface AccountClaimCandidate { + readonly personId: string; + readonly slug: string; + readonly fullName: string; + readonly memberOfCount: number; + readonly lastActiveAt: string; + readonly matchedVia: ReadonlyArray<'email' | 'username'>; + readonly matchedEmail: string | null; +} + +export interface AccountClaimCandidatesPayload { + readonly ghLogin: string; + readonly ghName: string | null; + readonly candidates: AccountClaimCandidate[]; +} + +export interface AccountClaimSessionResult { + readonly person: PersonDetail; + readonly accountLevel: 'user' | 'staff' | 'administrator'; +} + +export interface AccountClaimQueueItem { + readonly requestId: string; + readonly type: 'pre-onboarding' | 'post-onboarding-merge'; + readonly claimedSlug: string; + readonly claimedPersonId: string | null; + readonly requesterGithubLogin: string; + readonly requesterPersonId: string | null; + readonly evidence: string; + readonly submittedAt: string; +} + +export interface AccountClaimDecision { + readonly requestId: string; + readonly status: 'open' | 'approved' | 'denied'; + readonly person?: PersonDetail | null; +} + export interface CreateTagInput { namespace: 'topic' | 'tech' | 'event'; slug: string; @@ -656,4 +694,63 @@ export const api = { revokeSession: (jti: string): Promise => request(`/api/auth/sessions/${encodeURIComponent(jti)}/revoke`, { method: 'POST' }), }, + accountClaim: { + candidates: (): Promise> => + request(`/api/account-claim/candidates`), + confirm: (personId: string): Promise> => + request(`/api/account-claim/confirm`, { + method: 'POST', + body: JSON.stringify({ personId }), + }), + decline: (): Promise> => + request(`/api/account-claim/decline`, { method: 'POST', body: '{}' }), + byPassword: ( + slug: string, + password: string, + ): Promise> => + request(`/api/account-claim/by-password`, { + method: 'POST', + body: JSON.stringify({ slug, password }), + }), + requestStaffReview: ( + claimedSlug: string, + evidence: string, + ): Promise> => + request(`/api/account-claim/request-staff-review`, { + method: 'POST', + body: JSON.stringify({ claimedSlug, evidence }), + }), + legacySearch: ( + q: string, + ): Promise> => + request(`/api/account-claim/legacy${buildQuery({ q })}`), + legacyRequest: ( + claimedSlug: string, + evidence: string, + ): Promise> => + request(`/api/account-claim/legacy/request`, { + method: 'POST', + body: JSON.stringify({ claimedSlug, evidence }), + }), + }, + staffAccountClaim: { + queue: (): Promise> => + request(`/api/staff/account-claim/queue`), + approve: ( + requestId: string, + reason?: string, + ): Promise> => + request(`/api/staff/account-claim/${encodeURIComponent(requestId)}/approve`, { + method: 'POST', + body: JSON.stringify(reason ? { reason } : {}), + }), + deny: ( + requestId: string, + reason?: string, + ): Promise> => + request(`/api/staff/account-claim/${encodeURIComponent(requestId)}/deny`, { + method: 'POST', + body: JSON.stringify(reason ? { reason } : {}), + }), + }, }; diff --git a/apps/web/src/pages/AccountClaim.tsx b/apps/web/src/pages/AccountClaim.tsx new file mode 100644 index 0000000..be71480 --- /dev/null +++ b/apps/web/src/pages/AccountClaim.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { toast } from 'sonner'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/hooks/useAuth'; +import { + api, + ApiError, + type AccountClaimCandidate, + type AccountClaimCandidatesPayload, +} from '@/lib/api'; +import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time'; + +function safeReturn(input: string | null): string { + if (!input) return '/'; + if (!input.startsWith('/') || input.startsWith('//')) return '/'; + return input; +} + +export function AccountClaim() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { reload, person: existingPerson, loading: authLoading } = useAuth(); + const returnPath = safeReturn(searchParams.get('return')); + + const [loading, setLoading] = useState(true); + const [payload, setPayload] = useState(null); + const [confirming, setConfirming] = useState(null); + const [declining, setDeclining] = useState(false); + + useEffect(() => { + let cancelled = false; + // If the user already has a session, the claim flow doesn't apply. + // Per specs/screens/account-claim.md: when cfp_session is present, the + // claim screen sends the user to /account. + if (!authLoading && existingPerson) { + void navigate('/account', { replace: true }); + return () => { + cancelled = true; + }; + } + void (async () => { + try { + const res = await api.accountClaim.candidates(); + if (cancelled) return; + setPayload(res.data); + } catch (err) { + if (cancelled) return; + if (err instanceof ApiError && err.status === 401) { + // Missing or expired claim cookie → send to login + void navigate(`/login?return=${encodeURIComponent(returnPath)}`, { replace: true }); + return; + } + toast.error(err instanceof ApiError ? err.message : 'Failed to load claim candidates'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [authLoading, existingPerson, navigate, returnPath]); + + const onConfirm = async (candidate: AccountClaimCandidate) => { + if (!candidate.matchedEmail) { + // Username-only: take them to the password verify flow with slug pre-filled + void navigate( + `/account-claim/by-password?slug=${encodeURIComponent(candidate.slug)}&return=${encodeURIComponent(returnPath)}`, + ); + return; + } + setConfirming(candidate.personId); + try { + await api.accountClaim.confirm(candidate.personId); + toast.success(`Welcome back, ${candidate.fullName}`); + await reload(); + void navigate(returnPath, { replace: true }); + } catch (err) { + const msg = err instanceof ApiError ? err.message : 'Failed to confirm'; + toast.error(msg); + setConfirming(null); + } + }; + + const onDecline = async () => { + setDeclining(true); + try { + await api.accountClaim.decline(); + toast.success("Got it — we'll set up a fresh profile"); + await reload(); + void navigate(returnPath, { replace: true }); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to start fresh'); + setDeclining(false); + } + }; + + if (loading || authLoading) { + return ( +
+
+
+ ); + } + + if (!payload) { + // Error path already handled by toast + redirect — render nothing + return null; + } + + const candidates = payload.candidates; + const anyEmailMatch = candidates.some((c) => c.matchedVia.includes('email')); + const header = anyEmailMatch ? 'Welcome back' : 'Almost there'; + + return ( +
+
+ + + {header} + + We think you might have a Code for Philly account from before our + recent upgrade. We're trying to connect your GitHub identity to it + so you don't lose your project memberships and history. + + + + + {candidates.length === 0 && ( + + + No candidates + + We couldn't find an account to suggest. You can start fresh, or + try the password-verify flow if you remember an old slug. + + + + + + + + )} + + {candidates.map((c) => ( + + + {c.fullName} + + {c.slug} + {c.memberOfCount > 0 && ( + <> + {' · '}member of {c.memberOfCount} project{c.memberOfCount === 1 ? '' : 's'} + + )} + + + +
+ Last updated {formatRelativeTime(c.lastActiveAt)} +
+ {c.matchedEmail ? ( +
+ Matched via {c.matchedEmail} +
+ ) : ( +
+ Matched via username only — please verify with old password +
+ )} + + {c.matchedEmail ? ( + + ) : ( + + )} +
+
+ ))} + + {candidates.length > 0 && ( + + + + + + Have an old account we didn't find? Verify with password → + + + I don't have the password either — request staff review → + + + + )} +
+
+ ); +} diff --git a/apps/web/src/pages/AccountClaimByPassword.tsx b/apps/web/src/pages/AccountClaimByPassword.tsx new file mode 100644 index 0000000..36dd04c --- /dev/null +++ b/apps/web/src/pages/AccountClaimByPassword.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { toast } from 'sonner'; +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, ApiError } from '@/lib/api'; +import { useAuth } from '@/hooks/useAuth'; + +function safeReturn(input: string | null): string { + if (!input) return '/'; + if (!input.startsWith('/') || input.startsWith('//')) return '/'; + return input; +} + +export function AccountClaimByPassword() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { reload } = useAuth(); + const returnPath = safeReturn(searchParams.get('return')); + + const [slug, setSlug] = useState(searchParams.get('slug') ?? ''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await api.accountClaim.byPassword(slug.trim(), password); + toast.success('Welcome back'); + await reload(); + void navigate(returnPath, { replace: true }); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 401 && err.code === 'claim_token_invalid') { + // Claim cookie expired — restart the flow + void navigate(`/login?return=${encodeURIComponent(returnPath)}`, { replace: true }); + return; + } + if (err.status === 401) { + setError("Username or password didn't match"); + } else { + setError(err.message); + } + } else { + setError('Something went wrong'); + } + setSubmitting(false); + } + }; + + return ( +
+ + + Verify with old password + + Enter your pre-cutover Code for Philly username and password. We'll + connect your new GitHub identity to that legacy account. + + + +
+
+ + setSlug(e.target.value)} + autoComplete="username" + autoFocus={!slug} + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + autoFocus={!!slug} + required + /> +
+ {error && ( +
+ {error} +
+ )} + +
+ + I don't remember my password — request staff review → + + + ← Back to suggestions + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/pages/AccountClaimLegacy.tsx b/apps/web/src/pages/AccountClaimLegacy.tsx new file mode 100644 index 0000000..b9eecd6 --- /dev/null +++ b/apps/web/src/pages/AccountClaimLegacy.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { toast } from 'sonner'; +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 { Textarea } from '@/components/ui/textarea'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/hooks/useAuth'; +import { + api, + ApiError, + type AccountClaimCandidate, +} from '@/lib/api'; + +export function AccountClaimLegacy() { + const { person, loading } = useAuth(); + const navigate = useNavigate(); + + const [q, setQ] = useState(''); + const [searched, setSearched] = useState(false); + const [searching, setSearching] = useState(false); + const [candidate, setCandidate] = useState(null); + const [evidence, setEvidence] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (!loading && !person) { + void navigate('/login?return=/account/claim-legacy', { replace: true }); + } + }, [loading, person, navigate]); + + const onSearch = async (event: React.FormEvent) => { + event.preventDefault(); + setSearching(true); + setSearched(false); + try { + const res = await api.accountClaim.legacySearch(q.trim()); + setCandidate(res.data.candidates[0] ?? null); + setSearched(true); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Search failed'); + } finally { + setSearching(false); + } + }; + + const onSubmitMerge = async (event: React.FormEvent) => { + event.preventDefault(); + if (!candidate) return; + setSubmitting(true); + try { + await api.accountClaim.legacyRequest(candidate.slug, evidence.trim()); + setSubmitted(true); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to submit'); + } finally { + setSubmitting(false); + } + }; + + if (loading || !person) { + return ( +
Loading…
+ ); + } + + return ( +
+ + + Claim a legacy account + + Had a Code for Philly account from before the GitHub sign-in change + that we didn't surface at sign-in? Search for it here. Merges go + through staff review. + + + +
+
+ + setQ(e.target.value)} + placeholder="janedoe or jane@old-email.com" + required + /> +
+ +
+
+
+ + {searched && !candidate && ( + + + Nothing matched + + We couldn't find a legacy account from that. If you remember the + old username, you can still file a staff-review request below. + + + +
+
+ + setQ(e.target.value)} + required + /> +
+
+ +