Endpoints for the legacy-account claim flow. See behaviors/account-migration.md for the full rule set.
Most endpoints accept a claim-pending JWT in the cfp_claim cookie. This is a short-lived (5 min) JWT minted by /api/auth/github/callback when candidate Persons are surfaced. It carries:
{
"sub": "<gh.id>",
"scope": "claim",
"candidates": ["<personId1>", "<personId2>"],
"ghLogin": "...",
"ghName": "...",
"ghEmails": [{ "email": "...", "primary": true, "verified": true }, ...],
"iat": ...,
"exp": ...
}The presence of scope: "claim" distinguishes this from a regular session JWT. Endpoints validate the scope explicitly — a claim JWT cannot perform regular API actions and vice versa.
| Method | Path | Auth | Summary |
|---|---|---|---|
GET |
/api/account-claim/candidates |
claim-pending | Return the candidate Persons surfaced at OAuth callback. |
POST |
/api/account-claim/confirm |
claim-pending | Confirm a specific candidate (email-match path). |
POST |
/api/account-claim/decline |
claim-pending | Decline all candidates; create a fresh Person. |
POST |
/api/account-claim/by-password |
claim-pending | Verify old slug + password to claim a candidate. |
POST |
/api/account-claim/request-staff-review |
claim-pending | Submit a free-form claim request for staff to review. |
GET |
/api/account-claim/legacy |
user | Post-onboarding: search for a legacy account to claim. |
POST |
/api/account-claim/legacy/request |
user | Submit a post-onboarding claim request for staff review. |
GET |
/api/staff/account-claim/queue |
staff | List pending claim requests. |
POST |
/api/staff/account-claim/:requestId/approve |
staff | Approve a claim request. |
POST |
/api/staff/account-claim/:requestId/deny |
staff | Deny a claim request. |
Returns the candidates surfaced at OAuth callback, with enough detail for the user to recognize themselves.
{
"success": true,
"data": {
"ghLogin": "janedoe",
"ghName": "Jane Doe",
"candidates": [
{
"personId": "01951a3c-...",
"slug": "janedoe",
"fullName": "Jane Doe",
"memberOfCount": 3,
"lastActiveAt": "2024-08-15T...",
"matchedVia": ["email", "username"],
"matchedEmail": "jane@example.com"
}
]
}
}Each candidate's public-safe summary lets the user recognize themselves without exposing other private data. matchedVia is a hint to the UI (and the user) about why this candidate showed up.
401 unauthenticatedwith codeclaim_token_invalid—cfp_claimcookie missing or expired
The user has selected a candidate and confirms it's them. This works only for email-match candidates — username-match alone is too weak to auto-claim.
{ "personId": "01951a3c-..." }- Validate the
cfp_claimJWT and check thatpersonIdis in the embeddedcandidatesarray - Re-verify the match: at least one of the user's
gh.emails[].verifiedmust equalPrivateProfile.emailfor the claimed Person - Inside a
repo.transact: setPerson.githubUserId,Person.githubLogin,Person.githubLinkedAton the legacy Person - PUT private store: update
PrivateProfile.emailto the GitHub primary verified email andemailRefreshedAt = now; delete theLegacyPasswordCredentialif present - Clear
cfp_claimcookie - Issue session JWT pair (access + refresh)
- Return 200 with
{ person, accountLevel }
{ "success": true, "data": { "person": { /* ... */ }, "accountLevel": "user" } }Sets cfp_session + cfp_refresh cookies. Clears cfp_claim.
401 unauthenticatedwith codeclaim_token_invalid403 forbiddenwith codenot_a_candidate—personIdisn't in the claim JWT's candidate list403 forbiddenwith codeemail_match_required— username-only match; user must useby-passwordorrequest-staff-reviewinstead409 conflictwith codealready_claimed— the candidate has been claimed by someone else between the OAuth flow and this call (race; rare)
User says none of the candidates are them; create a fresh Person.
Empty.
Same shape as confirm. Creates a new Person + PrivateProfile from the GitHub identity, issues session.
The declined candidates are not modified — they remain available for someone else (the right user) to claim later.
Verify a legacy username + password to claim. The user supplies these explicitly — typically when their pre-cutover email is dead and email-match didn't work.
{
"slug": "janedoe",
"password": "..."
}- Validate
cfp_claimJWT (the user must have completed OAuth first) - Look up the Person by
slug. If not found OR ifPerson.githubUserId is not null(already claimed): return uniform 401 - Fetch
LegacyPasswordCredential.passwordHashfor that Person from the private store. If not found: return uniform 401 - Verify the supplied password against the hash using the legacy algorithm
- On match: proceed as in
confirm(link GitHub identity, refresh email, deleteLegacyPasswordCredential, issue session)
Same as confirm.
401 unauthenticatedwith codeclaim_credentials_invalid— uniform response for "no such slug," "already claimed," or "wrong password"401 unauthenticatedwith codeclaim_token_invalid—cfp_claimcookie missing or expired
Submit a free-form claim request. Used when neither A nor B is available (dead email AND lost password).
{
"claimedSlug": "janedoe",
"evidence": "I'm @alice in CFP Slack. I used the account in 2021–2023 for the PHLASK project. Email me at jane@new-job.com to verify."
}- Validate
cfp_claimJWT - Create an
AccountClaimRequestrecord in the private store withpersonIdof the claimed slug (if it exists), the user's GitHub identity, and the free-form evidence - Return 202 regardless of whether the slug exists (anti-enumeration)
{ "success": true, "data": { "delivered": true } }The user is informed that a staff member will follow up via the email they listed in the evidence (or via Slack DM).
Post-onboarding entry point. The user is signed in (via a fresh account or a previously-claimed legacy account) and realizes they had another legacy account.
| Param | Required | Notes |
|---|---|---|
q |
yes | The old slug or old email they remember |
Returns at most one matching candidate (or zero). Same shape as the candidate object in GET /candidates, with matchedVia set based on what q matched.
Anti-enumeration: if nothing matches, response is still 200 with an empty candidates array. We don't reveal which slugs exist.
Submit a staff-review request from the post-onboarding flow.
{
"claimedSlug": "janedoe",
"evidence": "..."
}Same shape as /api/account-claim/request-staff-review. The difference: the user is already signed in to a Person, so the request is linked to both identities — staff approval merges them per behaviors/account-migration.md#merge-semantics.
Lists pending AccountClaimRequest records.
{
"success": true,
"data": [
{
"requestId": "...",
"claimedSlug": "janedoe",
"claimedPersonId": "01951a3c-...",
"requesterGithubLogin": "janedoe",
"requesterPersonId": null,
"evidence": "...",
"submittedAt": "...",
"type": "pre-onboarding" | "post-onboarding-merge"
}
]
}Approve a request. For pre-onboarding (no requesterPersonId): link the GitHub identity to the claimed Person, issue the user's first session next time they sign in. For post-onboarding (has requesterPersonId): merge the requester's fresh Person into the claimed legacy Person.
Inside a repo.transact:
- Pre-onboarding: set
Person.githubUserId/Login/LinkedAton the claimed Person - Post-onboarding-merge: re-point all records authored by
requesterPersonIdtoclaimedPersonId, setPerson.githubUserIdfrom the requester onto the claimed Person, hard-delete the requester Person, write aslug-historyentry redirecting the requester's old slug
Audit-logged via commit trailers (Action: account-claim.approve, Subject-Slug: <claimed slug>, Actor-Slug: <staff slug>, Reason: <staff note>).
Mark the request denied. Optionally include a denial reason that's emailed to the requester.
AccountClaimRequestrecords live in the private store (they contain free-form evidence that may include PII). Storage path:account-claim-requests.jsonlin the private bucket, alongsideprofiles.jsonlandlegacy-passwords.jsonl. (Filed under behaviors/private-storage.md as a third entity in the private store.)- The post-onboarding merge is admin-mediated to prevent accidental or malicious self-merges. There's no self-service "merge two accounts I have" endpoint.
- All claim approvals/denials produce commit trailers per behaviors/storage.md — the public audit log records that a claim happened, even though the evidence and email matchers are private.
- api/auth.md
- behaviors/account-migration.md
- behaviors/private-storage.md
- behaviors/authorization.md — staff endpoints require
stafforadministrator - screens/account-claim.md