From 6f2ef46aad6d8b290af5c3588eb34df73134d5d7 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Wed, 27 Sep 2023 16:08:58 -0500 Subject: [PATCH] Email confirmation/update (#1568) * lexicons * codegen * email templates * request routes * impl * migration * tidy * tests * tidy & bugfixes * format * fix api test * fix auth test * codegen * add unique constraint * Add email confirmed to AtpSessionData * interop test files (#1529) * initial interop-test-files * crypto: switch signature-fixtures.json to a symlink * syntax: test against interop files * prettier * Update interop-test-files/README.md Co-authored-by: Eric Bailey * disable prettier on test vectors --------- Co-authored-by: Eric Bailey Co-authored-by: dholms * add getSuggestedFollowsByActor (#1553) * add getSuggestedFollowsByActor lex * remove pagination * codegen * add pds route * add app view route * first pass at likes-based suggested actors, plus tests * format * backfill with suggested_follow table * combine actors queries * fall back to popular follows, handle backfill differently * revert seed change, update test * lower likes threshold * cleanup * remove todo * format * optimize queries * cover mute lists * clean up into pipeline steps * add changeset * List feeds (#1557) * lexicons for block lists * reorg blockset functionality into graph service, impl block/mute filtering * apply filterBlocksAndMutes() throughout appview except feeds * update local feeds to pass through cleanFeedSkeleton(), offload block/mute application * impl for grabbing block/mute details by did pair * refactor getActorInfos away, use actor service * experiment with moving getFeedGenerators over to a pipeline * move getPostThread over to a pipeline * move feeds over to pipelines * move suggestions and likes over to pipelines * move reposted-by, follows, followers over to pipelines, tidy author feed and post thread * remove old block/mute checks * unify post presentation logic * move profiles endpoints over to pipelines * tidy * tidy * misc fixes * unify some profile hydration/presentation in appview * profile detail, split hydration and presentation, misc fixes * unify feed hydration w/ profile hydration * unify hydration step for embeds, tidy application of labels * setup indexing of list-blocks in bsky appview * apply list-blocks, impl getListBlocks, tidy getList, tests * tidy * update pds proxy snaps * update pds proxy snaps * fix snap * make algos return feed items, save work in getFeed * misc changes, tidy * tidy * fix aturi import * lex * list purpose * lex gen * add route * add proxy route * seed client helpers * tests * mutes and blocks * proxy test * snapshot * hoist actors out of composeThread() * tidy * tidy * run ci on all prs * format * format * fix snap name * fix snapsh --------- Co-authored-by: Devin Ivy * Improve xrpc server error handling (#1597) improve xrpc server error handling * Remove appview proxy runtime flags (#1590) * remove appview proxy runtime flags * clean up proxy tests * getPopular hotfix (#1599) dont pass all params * Interaction Gating (#1561) * lexicons for block lists * reorg blockset functionality into graph service, impl block/mute filtering * apply filterBlocksAndMutes() throughout appview except feeds * update local feeds to pass through cleanFeedSkeleton(), offload block/mute application * impl for grabbing block/mute details by did pair * refactor getActorInfos away, use actor service * experiment with moving getFeedGenerators over to a pipeline * move getPostThread over to a pipeline * move feeds over to pipelines * move suggestions and likes over to pipelines * move reposted-by, follows, followers over to pipelines, tidy author feed and post thread * remove old block/mute checks * unify post presentation logic * move profiles endpoints over to pipelines * tidy * tidy * misc fixes * unify some profile hydration/presentation in appview * profile detail, split hydration and presentation, misc fixes * unify feed hydration w/ profile hydration * unify hydration step for embeds, tidy application of labels * setup indexing of list-blocks in bsky appview * apply list-blocks, impl getListBlocks, tidy getList, tests * tidy * update pds proxy snaps * update pds proxy snaps * fix snap * make algos return feed items, save work in getFeed * misc changes, tidy * tidy * fix aturi import * initial lexicons for interaction-gating * add interactions view to post views * codegen * model bad reply/interaction check state on posts * initial impl for checking bad reply or interaction on write * omit invalid interactions from post thread * support not-found list in interaction view * hydrate can-reply state on threads * present interaction views on posts * misc fixes, update snaps * tidy/reorg * tidy * split interaction gating into separate record in lexicon * switch interaction-gating impl to use separate record type * allow checking reply gate w/ root post deletion * fix * initial gating tests * tighten gated reply views, tests * reply-gating list rule tests * allow custom post rkeys within window * hoist actors out of composeThread() * tidy * update thread gate lexicons, codegen * lex fix * rename gate to threadgate in bsky, update views * lex fix * improve terminology around reply validation * fix down migration * remove thread gates on actor unindexing * add back .prettierignore * tidy * run ci on all prs * syntax * run ci on all prs * format * fix snap --------- Co-authored-by: Devin Ivy * order by `like.indexedAt` in app view (#1592) * order by like.indexedAt * use keyset for ordering * simplify * ok ok ok I get it now * Update packages/bsky/src/api/app/bsky/feed/getActorLikes.ts Co-authored-by: Daniel Holmgren --------- Co-authored-by: Daniel Holmgren * Remove default value for post table invalid attrs (#1601) remove default value for post table attrs * Version packages (#1602) Co-authored-by: github-actions[bot] * update Bluesky PBLLC to PBC (Public Benefit Corporation) (#1600) * Temporarily disable filtering `invalidReplyRoot`s (#1609) temporarily disable invalidReplyRoot check * fix syntax docs (#1611) * Version packages (#1612) Co-authored-by: github-actions[bot] * Allow bypass on ratelimit ip (#1613) allow bypass on ratelimit ip * Write rate limits (#1578) * get rate limit ip correctly * add write rate-limits * Tweak createSession rate limit key (#1614) tweak create session rl key * Filter preferences for app passwords (#1626) filter preferences for app passwords * Tweak rate limit setup for multi rate limit routes (#1627) tweak rate limit setup for multi rate limit routes * Remove zod from xrpc-server error handling (#1631) remove zod from xrpc-server error handling check * Enforce properties field on lexicon object schemas (#1628) * add empty properites to thread gate schema fragments * tweak lexicon type * Add feed-vew and thread-view preferences (#1638) * Add feed and thread preference lexicons * Add feed-view and thread-view preference APIs * Add changeset for new preferences (#1639) Add changeset * Version packages (#1640) Co-authored-by: github-actions[bot] * Disable getAccountInviteCodes for app passwords (#1642) disable getAccountInviteCodes for app passwords * remove cruft packages (uri, nsid, identifier) (#1606) * remove @atproto/nsid (previously moved to syntax) * remove @atproto/uri (previously moved to syntax) * remove @atproto/identifier (previously moved to syntax) * bump lockfile to remove old packages --------- Co-authored-by: Eric Bailey * api: update login/resumeSession examples in README (#1634) * api: update login/resumeSession examples in README * Update packages/api/README.md Co-authored-by: Daniel Holmgren --------- Co-authored-by: Daniel Holmgren * small syntax lints (#1646) * lint: remove unused imports and variables * lint: prefix unused args with '_' * eslint: skip no-explicit-any; ignore unused _var (prefix) * eslint: explicitly mark ignores for tricky cases * indicate that getPopular is deprecated (#1647) * indicate that getPopular is deprecated * codegen for deprecating getPopular * tidy up package.json and READMEs (#1649) * identity: README example and tidy * tidy up package metadata (package.json files) * updated README headers/stubs for several packages * crypto: longer README, with usage * syntax: tweak README * Apply suggestions from code review Co-authored-by: Eric Bailey Co-authored-by: devin ivy --------- Co-authored-by: Eric Bailey Co-authored-by: devin ivy * Improve the types of the thread and feed preferences APIs (#1653) * Improve the types of the thread and feed preferences APIs * Remove unused import * Add changeset * Version packages (#1654) Co-authored-by: github-actions[bot] * Disable pds appview routes (#1644) * wip * remove all canProxyReadc * finish cleanup * clean up tests * fix up tests * fix api tests * fix build * fix compression test * update image tests * fix dev envs * build branch * fix service file * re-enable getPopular * format * rm unused sharp code * dont build branch * auto-moderator tweaks: pass along record URI, create report for takedown action (#1643) * auto-moderator: include record URI in abyss requests * auto-moderator: log attempt at hard takedown; create report as well The motivation is to flag the event to mod team, and to make it easier to confirm that takedown took place. * auto-mod: typo fix * auto-mod: bugfixes * bsky: always create auto-mod report locally, not pushAgent (if possible) * bsky: fix auto-mod build * bsky: URL-encode scanBlob call * Clear follow viewer state when blocking (#1659) * clear follow viewer state when blocking * tidy * add `tags` to posts (#1637) * add tags to post lex * kiss * add richtext facet and validation attrs * add tag validation attrs to post * codegen * add maxLength for tags, add description * validate post tags on write * add test * handle tags in indexer * add tags to postView, codegen * return tags on post thread view * format * revert formatting change to docs * use establish validation pattern * add changeset (cherry picked from commit 464b8074f726fa12b0dc9887add3537ae85b8055) * remove tags from postView, codegen * remove tags from thread view * revert unused changes * Version packages (#1664) Co-authored-by: github-actions[bot] * merge * Reverse order of blocks from sync.getRepo (#1665) * reverse order of blocks from sync.getRepo * write to car while fetching next page * Add hashtag detection to richtext (#1651) * add tag detection to richtext * fix duplicate tag index error * add utils * fix leading space index failures, test for them * add changeset * Version packages (#1669) Co-authored-by: github-actions[bot] * proposed new search lexicons (#1594) * proposed new search lexicons * lexicons: lint * lexicons: fix actors typo * lexicons: camelCase bites again, ssssss * lexicons: add 'q' and mark 'term' as deprecated for search endpoints * codegen for search lexicon updates * bsky: prefer 'q' over 'term' in existing search endpoints * search: bugfix * lexicons: make unspecced search endpoints return skeleton obj * re-codegen for search skeleton obj * Disable pds appview indexing (#1645) * rm indexing service * remove message queue & refactor background queue * wip * remove all canProxyReadc * finish cleanup * clean up tests * fix up tests * fix api tests * fix build * fix compression test * update image tests * fix dev envs * build branch * wip - removing labeler * fix service file * remove kysely tables * re-enable getPopular * format * cleaning up tests * rm unused sharp code * rm pds build * clean up tests * fix build * fix build * migration * tidy * build branch * tidy * build branch * small tidy * dont build * Refactor PDS appview routes (#1673) move routes around * Strip leading `#` from from detected tag facets (#1674) ensure # is removed from facets * Version packages (#1675) Co-authored-by: github-actions[bot] * Proxy search queries (#1676) * proxy search * tweak profile resp * fix admin.searchRepos * add mock mailer * Fix to daniel's MOCKERY of a mock mailer * Don't allow non-verified email updates until app feature is out (#1682) stricter updating email until app feature is out * changesets --------- Co-authored-by: Paul Frazee Co-authored-by: bnewbold Co-authored-by: Eric Bailey Co-authored-by: Devin Ivy Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .changeset/little-fans-obey.md | 7 + .changeset/witty-islands-burn.md | 5 + lexicons/com/atproto/server/confirmEmail.json | 27 ++ .../com/atproto/server/createSession.json | 3 +- lexicons/com/atproto/server/getSession.json | 3 +- .../server/requestEmailConfirmation.json | 10 + .../atproto/server/requestEmailUpdate.json | 20 + lexicons/com/atproto/server/updateEmail.json | 29 ++ packages/api/src/agent.ts | 4 + packages/api/src/client/index.ts | 52 +++ packages/api/src/client/lexicons.ts | 122 ++++++ .../types/com/atproto/server/confirmEmail.ts | 61 +++ .../types/com/atproto/server/createSession.ts | 1 + .../types/com/atproto/server/getSession.ts | 1 + .../server/requestEmailConfirmation.ts | 28 ++ .../com/atproto/server/requestEmailUpdate.ts | 34 ++ .../types/com/atproto/server/updateEmail.ts | 55 +++ packages/api/src/types.ts | 1 + packages/api/tests/agent.test.ts | 9 + packages/bsky/src/lexicon/index.ts | 48 +++ packages/bsky/src/lexicon/lexicons.ts | 122 ++++++ .../types/com/atproto/server/confirmEmail.ts | 40 ++ .../types/com/atproto/server/createSession.ts | 1 + .../types/com/atproto/server/getSession.ts | 1 + .../server/requestEmailConfirmation.ts | 31 ++ .../com/atproto/server/requestEmailUpdate.ts | 43 ++ .../types/com/atproto/server/updateEmail.ts | 41 ++ packages/common-web/src/times.ts | 4 + packages/dev-env/src/bin.ts | 2 + packages/dev-env/src/util.ts | 10 + .../api/com/atproto/server/confirmEmail.ts | 34 ++ .../api/com/atproto/server/createSession.ts | 1 + .../src/api/com/atproto/server/getSession.ts | 7 +- .../pds/src/api/com/atproto/server/index.ts | 10 + .../atproto/server/requestAccountDelete.ts | 4 +- .../server/requestEmailConfirmation.ts | 20 + .../com/atproto/server/requestEmailUpdate.ts | 31 ++ .../src/api/com/atproto/server/updateEmail.ts | 40 ++ packages/pds/src/db/database-schema.ts | 2 + .../20230926T195532354Z-email-tokens.ts | 28 ++ packages/pds/src/db/migrations/index.ts | 1 + packages/pds/src/db/tables/email-token.ts | 16 + packages/pds/src/db/tables/user-account.ts | 1 + packages/pds/src/lexicon/index.ts | 48 +++ packages/pds/src/lexicon/lexicons.ts | 122 ++++++ .../types/com/atproto/server/confirmEmail.ts | 40 ++ .../types/com/atproto/server/createSession.ts | 1 + .../types/com/atproto/server/getSession.ts | 1 + .../server/requestEmailConfirmation.ts | 31 ++ .../com/atproto/server/requestEmailUpdate.ts | 43 ++ .../types/com/atproto/server/updateEmail.ts | 41 ++ packages/pds/src/mailer/index.ts | 16 + .../src/mailer/templates/confirm-email.hbs | 382 +++++++++++++++++ .../pds/src/mailer/templates/update-email.hbs | 383 ++++++++++++++++++ packages/pds/src/services/account/index.ts | 50 ++- packages/pds/tests/account-deletion.test.ts | 2 + packages/pds/tests/auth.test.ts | 3 + packages/pds/tests/email-confirmation.test.ts | 209 ++++++++++ 58 files changed, 2375 insertions(+), 7 deletions(-) create mode 100644 .changeset/little-fans-obey.md create mode 100644 .changeset/witty-islands-burn.md create mode 100644 lexicons/com/atproto/server/confirmEmail.json create mode 100644 lexicons/com/atproto/server/requestEmailConfirmation.json create mode 100644 lexicons/com/atproto/server/requestEmailUpdate.json create mode 100644 lexicons/com/atproto/server/updateEmail.json create mode 100644 packages/api/src/client/types/com/atproto/server/confirmEmail.ts create mode 100644 packages/api/src/client/types/com/atproto/server/requestEmailConfirmation.ts create mode 100644 packages/api/src/client/types/com/atproto/server/requestEmailUpdate.ts create mode 100644 packages/api/src/client/types/com/atproto/server/updateEmail.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/server/confirmEmail.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts create mode 100644 packages/pds/src/api/com/atproto/server/confirmEmail.ts create mode 100644 packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts create mode 100644 packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts create mode 100644 packages/pds/src/api/com/atproto/server/updateEmail.ts create mode 100644 packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts create mode 100644 packages/pds/src/db/tables/email-token.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/server/confirmEmail.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts create mode 100644 packages/pds/src/mailer/templates/confirm-email.hbs create mode 100644 packages/pds/src/mailer/templates/update-email.hbs create mode 100644 packages/pds/tests/email-confirmation.test.ts diff --git a/.changeset/little-fans-obey.md b/.changeset/little-fans-obey.md new file mode 100644 index 00000000000..6fd0265b15b --- /dev/null +++ b/.changeset/little-fans-obey.md @@ -0,0 +1,7 @@ +--- +'@atproto/dev-env': patch +'@atproto/api': patch +'@atproto/pds': patch +--- + +Added email verification and update flows diff --git a/.changeset/witty-islands-burn.md b/.changeset/witty-islands-burn.md new file mode 100644 index 00000000000..b82cd22b8c1 --- /dev/null +++ b/.changeset/witty-islands-burn.md @@ -0,0 +1,5 @@ +--- +'@atproto/common-web': patch +--- + +Added lessThanAgoMs utility diff --git a/lexicons/com/atproto/server/confirmEmail.json b/lexicons/com/atproto/server/confirmEmail.json new file mode 100644 index 00000000000..6c2e4291f76 --- /dev/null +++ b/lexicons/com/atproto/server/confirmEmail.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.confirmEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Confirm an email using a token from com.atproto.server.requestEmailConfirmation.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email", "token"], + "properties": { + "email": { "type": "string" }, + "token": { "type": "string" } + } + } + }, + "errors": [ + { "name": "AccountNotFound" }, + { "name": "ExpiredToken" }, + { "name": "InvalidToken" }, + { "name": "InvalidEmail" } + ] + } + } +} diff --git a/lexicons/com/atproto/server/createSession.json b/lexicons/com/atproto/server/createSession.json index fc416ddabae..7d877cec91c 100644 --- a/lexicons/com/atproto/server/createSession.json +++ b/lexicons/com/atproto/server/createSession.json @@ -29,7 +29,8 @@ "refreshJwt": { "type": "string" }, "handle": { "type": "string", "format": "handle" }, "did": { "type": "string", "format": "did" }, - "email": { "type": "string" } + "email": { "type": "string" }, + "emailConfirmed": { "type": "boolean" } } } }, diff --git a/lexicons/com/atproto/server/getSession.json b/lexicons/com/atproto/server/getSession.json index 55b129be3df..7ff5569eb1b 100644 --- a/lexicons/com/atproto/server/getSession.json +++ b/lexicons/com/atproto/server/getSession.json @@ -13,7 +13,8 @@ "properties": { "handle": { "type": "string", "format": "handle" }, "did": { "type": "string", "format": "did" }, - "email": { "type": "string" } + "email": { "type": "string" }, + "emailConfirmed": { "type": "boolean" } } } } diff --git a/lexicons/com/atproto/server/requestEmailConfirmation.json b/lexicons/com/atproto/server/requestEmailConfirmation.json new file mode 100644 index 00000000000..4b2470bf59b --- /dev/null +++ b/lexicons/com/atproto/server/requestEmailConfirmation.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.requestEmailConfirmation", + "defs": { + "main": { + "type": "procedure", + "description": "Request an email with a code to confirm ownership of email" + } + } +} diff --git a/lexicons/com/atproto/server/requestEmailUpdate.json b/lexicons/com/atproto/server/requestEmailUpdate.json new file mode 100644 index 00000000000..4cc1a86f612 --- /dev/null +++ b/lexicons/com/atproto/server/requestEmailUpdate.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.requestEmailUpdate", + "defs": { + "main": { + "type": "procedure", + "description": "Request a token in order to update email.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["tokenRequired"], + "properties": { + "tokenRequired": { "type": "boolean" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/server/updateEmail.json b/lexicons/com/atproto/server/updateEmail.json new file mode 100644 index 00000000000..88872698910 --- /dev/null +++ b/lexicons/com/atproto/server/updateEmail.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.updateEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Update an account's email.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string" }, + "token": { + "type": "string", + "description": "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed." + } + } + } + }, + "errors": [ + { "name": "ExpiredToken" }, + { "name": "InvalidToken" }, + { "name": "TokenRequired" } + ] + } + } +} diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index d5af9b63ddc..2cd4c44e7a0 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -95,6 +95,7 @@ export class AtpAgent { handle: res.data.handle, did: res.data.did, email: opts.email, + emailConfirmed: false, } return res } catch (e) { @@ -126,6 +127,7 @@ export class AtpAgent { handle: res.data.handle, did: res.data.did, email: res.data.email, + emailConfirmed: res.data.emailConfirmed, } return res } catch (e) { @@ -154,6 +156,7 @@ export class AtpAgent { } this.session.email = res.data.email this.session.handle = res.data.handle + this.session.emailConfirmed = res.data.emailConfirmed return res } catch (e) { this.session = undefined @@ -268,6 +271,7 @@ export class AtpAgent { } else if (isNewSessionObject(this._baseClient, res.body)) { // succeeded, update the session this.session = { + ...(this.session || {}), accessJwt: res.body.accessJwt, refreshJwt: res.body.refreshJwt, handle: res.body.handle, diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 982117cef02..e5286aa2eb1 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -41,6 +41,7 @@ import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +import * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -55,9 +56,12 @@ import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +import * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +import * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +import * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -170,6 +174,7 @@ export * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords export * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' export * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +export * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' export * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' export * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' export * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -184,9 +189,12 @@ export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi export * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' export * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +export * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +export * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' export * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' export * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' export * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +export * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' export * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' export * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' export * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -712,6 +720,17 @@ export class ServerNS { this._service = service } + confirmEmail( + data?: ComAtprotoServerConfirmEmail.InputSchema, + opts?: ComAtprotoServerConfirmEmail.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.confirmEmail', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerConfirmEmail.toKnownErr(e) + }) + } + createAccount( data?: ComAtprotoServerCreateAccount.InputSchema, opts?: ComAtprotoServerCreateAccount.CallOptions, @@ -855,6 +874,28 @@ export class ServerNS { }) } + requestEmailConfirmation( + data?: ComAtprotoServerRequestEmailConfirmation.InputSchema, + opts?: ComAtprotoServerRequestEmailConfirmation.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.requestEmailConfirmation', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerRequestEmailConfirmation.toKnownErr(e) + }) + } + + requestEmailUpdate( + data?: ComAtprotoServerRequestEmailUpdate.InputSchema, + opts?: ComAtprotoServerRequestEmailUpdate.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.requestEmailUpdate', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerRequestEmailUpdate.toKnownErr(e) + }) + } + requestPasswordReset( data?: ComAtprotoServerRequestPasswordReset.InputSchema, opts?: ComAtprotoServerRequestPasswordReset.CallOptions, @@ -887,6 +928,17 @@ export class ServerNS { throw ComAtprotoServerRevokeAppPassword.toKnownErr(e) }) } + + updateEmail( + data?: ComAtprotoServerUpdateEmail.InputSchema, + opts?: ComAtprotoServerUpdateEmail.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.updateEmail', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerUpdateEmail.toKnownErr(e) + }) + } } export class SyncNS { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 177b63808f4..77b4939d758 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2242,6 +2242,46 @@ export const schemaDict = { }, }, }, + ComAtprotoServerConfirmEmail: { + lexicon: 1, + id: 'com.atproto.server.confirmEmail', + defs: { + main: { + type: 'procedure', + description: + 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email', 'token'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + }, + }, + }, + }, + errors: [ + { + name: 'AccountNotFound', + }, + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'InvalidEmail', + }, + ], + }, + }, + }, ComAtprotoServerCreateAccount: { lexicon: 1, id: 'com.atproto.server.createAccount', @@ -2526,6 +2566,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2756,6 +2799,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2854,6 +2900,39 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRequestEmailConfirmation: { + lexicon: 1, + id: 'com.atproto.server.requestEmailConfirmation', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to confirm ownership of email', + }, + }, + }, + ComAtprotoServerRequestEmailUpdate: { + lexicon: 1, + id: 'com.atproto.server.requestEmailUpdate', + defs: { + main: { + type: 'procedure', + description: 'Request a token in order to update email.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['tokenRequired'], + properties: { + tokenRequired: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRequestPasswordReset: { lexicon: 1, id: 'com.atproto.server.requestPasswordReset', @@ -2931,6 +3010,44 @@ export const schemaDict = { }, }, }, + ComAtprotoServerUpdateEmail: { + lexicon: 1, + id: 'com.atproto.server.updateEmail', + defs: { + main: { + type: 'procedure', + description: "Update an account's email.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + description: + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", + }, + }, + }, + }, + errors: [ + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'TokenRequired', + }, + ], + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -7240,6 +7357,7 @@ export const ids = { ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + ComAtprotoServerConfirmEmail: 'com.atproto.server.confirmEmail', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', @@ -7256,10 +7374,14 @@ export const ids = { ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', + ComAtprotoServerRequestEmailConfirmation: + 'com.atproto.server.requestEmailConfirmation', + ComAtprotoServerRequestEmailUpdate: 'com.atproto.server.requestEmailUpdate', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', + ComAtprotoServerUpdateEmail: 'com.atproto.server.updateEmail', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/api/src/client/types/com/atproto/server/confirmEmail.ts b/packages/api/src/client/types/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..eb53dc5a0dc --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/confirmEmail.ts @@ -0,0 +1,61 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + email: string + token: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export class AccountNotFoundError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class ExpiredTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class InvalidTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class InvalidEmailError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + if (e.error === 'AccountNotFound') return new AccountNotFoundError(e) + if (e.error === 'ExpiredToken') return new ExpiredTokenError(e) + if (e.error === 'InvalidToken') return new InvalidTokenError(e) + if (e.error === 'InvalidEmail') return new InvalidEmailError(e) + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/createSession.ts b/packages/api/src/client/types/com/atproto/server/createSession.ts index d86f2aef1d4..08d2bcd6225 100644 --- a/packages/api/src/client/types/com/atproto/server/createSession.ts +++ b/packages/api/src/client/types/com/atproto/server/createSession.ts @@ -22,6 +22,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/getSession.ts b/packages/api/src/client/types/com/atproto/server/getSession.ts index c15836dfb77..91d51860982 100644 --- a/packages/api/src/client/types/com/atproto/server/getSession.ts +++ b/packages/api/src/client/types/com/atproto/server/getSession.ts @@ -15,6 +15,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/requestEmailConfirmation.ts b/packages/api/src/client/types/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..ef2ed1ac47c --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,28 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface CallOptions { + headers?: Headers + qp?: QueryParams +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/requestEmailUpdate.ts b/packages/api/src/client/types/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..30d84002cf2 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,34 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + tokenRequired: boolean + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/updateEmail.ts b/packages/api/src/client/types/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..92aef734e20 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/updateEmail.ts @@ -0,0 +1,55 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + email: string + /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ + token?: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export class ExpiredTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class InvalidTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class TokenRequiredError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + if (e.error === 'ExpiredToken') return new ExpiredTokenError(e) + if (e.error === 'InvalidToken') return new InvalidTokenError(e) + if (e.error === 'TokenRequired') return new TokenRequiredError(e) + } + return e +} diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 10d0bbd90fe..c0f78bfaafc 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -14,6 +14,7 @@ export interface AtpSessionData { handle: string did: string email?: string + emailConfirmed?: boolean } /** diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index c0fb67b9902..82d590ec85b 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -48,6 +48,7 @@ describe('agent', () => { expect(agent.session?.handle).toEqual(res.data.handle) expect(agent.session?.did).toEqual(res.data.did) expect(agent.session?.email).toEqual('user1@test.com') + expect(agent.session?.emailConfirmed).toEqual(false) const { data: sessionInfo } = await agent.api.com.atproto.server.getSession( {}, @@ -56,6 +57,7 @@ describe('agent', () => { did: res.data.did, handle: res.data.handle, email: 'user1@test.com', + emailConfirmed: false, }) expect(events.length).toEqual(1) @@ -93,6 +95,7 @@ describe('agent', () => { expect(agent2.session?.handle).toEqual(res1.data.handle) expect(agent2.session?.did).toEqual(res1.data.did) expect(agent2.session?.email).toEqual('user2@test.com') + expect(agent2.session?.emailConfirmed).toEqual(false) const { data: sessionInfo } = await agent2.api.com.atproto.server.getSession({}) @@ -100,6 +103,7 @@ describe('agent', () => { did: res1.data.did, handle: res1.data.handle, email, + emailConfirmed: false, }) expect(events.length).toEqual(2) @@ -142,6 +146,7 @@ describe('agent', () => { did: res1.data.did, handle: res1.data.handle, email: res1.data.email, + emailConfirmed: false, }) expect(events.length).toEqual(2) @@ -206,6 +211,8 @@ describe('agent', () => { expect(agent.session?.refreshJwt).not.toEqual(session1.refreshJwt) expect(agent.session?.handle).toEqual(session1.handle) expect(agent.session?.did).toEqual(session1.did) + expect(agent.session?.email).toEqual(session1.email) + expect(agent.session?.emailConfirmed).toEqual(session1.emailConfirmed) expect(events.length).toEqual(2) expect(events[0]).toEqual('create') @@ -283,6 +290,8 @@ describe('agent', () => { expect(agent.session?.refreshJwt).not.toEqual(session1.refreshJwt) expect(agent.session?.handle).toEqual(session1.handle) expect(agent.session?.did).toEqual(session1.did) + expect(agent.session?.email).toEqual(session1.email) + expect(agent.session?.emailConfirmed).toEqual(session1.emailConfirmed) expect(events.length).toEqual(2) expect(events[0]).toEqual('create') diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 3dd2b5104cc..ac6ca933fcd 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -39,6 +39,7 @@ import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +import * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -52,9 +53,12 @@ import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +import * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +import * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +import * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -557,6 +561,17 @@ export class ServerNS { this._server = server } + confirmEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerConfirmEmail.Handler>, + ComAtprotoServerConfirmEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.confirmEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + createAccount( cfg: ConfigOf< AV, @@ -700,6 +715,28 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + requestEmailConfirmation( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailConfirmation.Handler>, + ComAtprotoServerRequestEmailConfirmation.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailConfirmation' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + requestEmailUpdate( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailUpdate.Handler>, + ComAtprotoServerRequestEmailUpdate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailUpdate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + requestPasswordReset( cfg: ConfigOf< AV, @@ -732,6 +769,17 @@ export class ServerNS { const nsid = 'com.atproto.server.revokeAppPassword' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + updateEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerUpdateEmail.Handler>, + ComAtprotoServerUpdateEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.updateEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class SyncNS { diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 177b63808f4..77b4939d758 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2242,6 +2242,46 @@ export const schemaDict = { }, }, }, + ComAtprotoServerConfirmEmail: { + lexicon: 1, + id: 'com.atproto.server.confirmEmail', + defs: { + main: { + type: 'procedure', + description: + 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email', 'token'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + }, + }, + }, + }, + errors: [ + { + name: 'AccountNotFound', + }, + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'InvalidEmail', + }, + ], + }, + }, + }, ComAtprotoServerCreateAccount: { lexicon: 1, id: 'com.atproto.server.createAccount', @@ -2526,6 +2566,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2756,6 +2799,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2854,6 +2900,39 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRequestEmailConfirmation: { + lexicon: 1, + id: 'com.atproto.server.requestEmailConfirmation', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to confirm ownership of email', + }, + }, + }, + ComAtprotoServerRequestEmailUpdate: { + lexicon: 1, + id: 'com.atproto.server.requestEmailUpdate', + defs: { + main: { + type: 'procedure', + description: 'Request a token in order to update email.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['tokenRequired'], + properties: { + tokenRequired: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRequestPasswordReset: { lexicon: 1, id: 'com.atproto.server.requestPasswordReset', @@ -2931,6 +3010,44 @@ export const schemaDict = { }, }, }, + ComAtprotoServerUpdateEmail: { + lexicon: 1, + id: 'com.atproto.server.updateEmail', + defs: { + main: { + type: 'procedure', + description: "Update an account's email.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + description: + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", + }, + }, + }, + }, + errors: [ + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'TokenRequired', + }, + ], + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -7240,6 +7357,7 @@ export const ids = { ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + ComAtprotoServerConfirmEmail: 'com.atproto.server.confirmEmail', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', @@ -7256,10 +7374,14 @@ export const ids = { ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', + ComAtprotoServerRequestEmailConfirmation: + 'com.atproto.server.requestEmailConfirmation', + ComAtprotoServerRequestEmailUpdate: 'com.atproto.server.requestEmailUpdate', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', + ComAtprotoServerUpdateEmail: 'com.atproto.server.updateEmail', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/confirmEmail.ts b/packages/bsky/src/lexicon/types/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..ffaeeb8fe75 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/confirmEmail.ts @@ -0,0 +1,40 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + token: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'AccountNotFound' | 'ExpiredToken' | 'InvalidToken' | 'InvalidEmail' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts index b836551f301..037900346a1 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts @@ -23,6 +23,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts index 388fb5eae9d..7f066a500bf 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts @@ -16,6 +16,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..e4244870425 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined +export type HandlerInput = undefined + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..6876d44ca46 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,43 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + tokenRequired: boolean + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts b/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..c88bd3021b2 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts @@ -0,0 +1,41 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ + token?: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'ExpiredToken' | 'InvalidToken' | 'TokenRequired' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/common-web/src/times.ts b/packages/common-web/src/times.ts index 09bb26efc97..90366277fdf 100644 --- a/packages/common-web/src/times.ts +++ b/packages/common-web/src/times.ts @@ -2,3 +2,7 @@ export const SECOND = 1000 export const MINUTE = SECOND * 60 export const HOUR = MINUTE * 60 export const DAY = HOUR * 24 + +export const lessThanAgoMs = (time: Date, range: number) => { + return Date.now() < time.getTime() + range +} diff --git a/packages/dev-env/src/bin.ts b/packages/dev-env/src/bin.ts index 56f0138b3b2..ae89c9abecb 100644 --- a/packages/dev-env/src/bin.ts +++ b/packages/dev-env/src/bin.ts @@ -1,5 +1,6 @@ import { generateMockSetup } from './mock' import { TestNetwork } from './network' +import { mockMailer } from './util' const run = async () => { console.log(` @@ -23,6 +24,7 @@ const run = async () => { }, plc: { port: 2582 }, }) + mockMailer(network.pds) await generateMockSetup(network) console.log( diff --git a/packages/dev-env/src/util.ts b/packages/dev-env/src/util.ts index 340d0414da1..0e62a733a8a 100644 --- a/packages/dev-env/src/util.ts +++ b/packages/dev-env/src/util.ts @@ -44,6 +44,16 @@ export const mockResolvers = (idResolver: IdResolver, pds: TestPds) => { } } +export const mockMailer = (pds: TestPds) => { + const mailer = pds.ctx.mailer + const _origSendMail = mailer.transporter.sendMail + mailer.transporter.sendMail = async (opts) => { + const result = await _origSendMail.call(mailer.transporter, opts) + console.log(`✉️ Email: ${JSON.stringify(result, null, 2)}`) + return result + } +} + const usedLockIds = new Set() export const uniqueLockId = () => { let lockId: number diff --git a/packages/pds/src/api/com/atproto/server/confirmEmail.ts b/packages/pds/src/api/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..f1452825771 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/confirmEmail.ts @@ -0,0 +1,34 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.confirmEmail({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth, input }) => { + const did = auth.credentials.did + const { token, email } = input.body + + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found', 'AccountNotFound') + } + + if (user.email !== email.toLowerCase()) { + throw new InvalidRequestError('invalid email', 'InvalidEmail') + } + await ctx.services + .account(ctx.db) + .assertValidToken(did, 'confirm_email', token) + + await ctx.db.transaction(async (dbTxn) => { + await ctx.services.account(dbTxn).deleteEmailToken(did, 'confirm_email') + await dbTxn.db + .updateTable('user_account') + .set({ emailConfirmedAt: new Date().toISOString() }) + .where('did', '=', did) + .execute() + }) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 3769cda08a6..6d8d57e471e 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -68,6 +68,7 @@ export default function (server: Server, ctx: AppContext) { did: user.did, handle: user.handle, email: user.email, + emailConfirmed: !!user.emailConfirmedAt, accessJwt: access.jwt, refreshJwt: refresh.jwt, }, diff --git a/packages/pds/src/api/com/atproto/server/getSession.ts b/packages/pds/src/api/com/atproto/server/getSession.ts index bfcccc97657..eb33180e6ff 100644 --- a/packages/pds/src/api/com/atproto/server/getSession.ts +++ b/packages/pds/src/api/com/atproto/server/getSession.ts @@ -15,7 +15,12 @@ export default function (server: Server, ctx: AppContext) { } return { encoding: 'application/json', - body: { handle: user.handle, did: user.did, email: user.email }, + body: { + handle: user.handle, + did: user.did, + email: user.email, + emailConfirmed: !!user.emailConfirmedAt, + }, } }, }) diff --git a/packages/pds/src/api/com/atproto/server/index.ts b/packages/pds/src/api/com/atproto/server/index.ts index 9a49216f71c..210d0f45461 100644 --- a/packages/pds/src/api/com/atproto/server/index.ts +++ b/packages/pds/src/api/com/atproto/server/index.ts @@ -14,6 +14,12 @@ import deleteAccount from './deleteAccount' import requestPasswordReset from './requestPasswordReset' import resetPassword from './resetPassword' +import requestEmailConfirmation from './requestEmailConfirmation' +import confirmEmail from './confirmEmail' + +import requestEmailUpdate from './requestEmailUpdate' +import updateEmail from './updateEmail' + import createSession from './createSession' import deleteSession from './deleteSession' import getSession from './getSession' @@ -33,6 +39,10 @@ export default function (server: Server, ctx: AppContext) { deleteAccount(server, ctx) requestPasswordReset(server, ctx) resetPassword(server, ctx) + requestEmailConfirmation(server, ctx) + confirmEmail(server, ctx) + requestEmailUpdate(server, ctx) + updateEmail(server, ctx) createSession(server, ctx) deleteSession(server, ctx) getSession(server, ctx) diff --git a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts index 61870c8c3ca..a448d97c02e 100644 --- a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts +++ b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts @@ -8,12 +8,12 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.accessVerifierCheckTakedown, handler: async ({ auth }) => { const did = auth.credentials.did - const token = getRandomToken().toUpperCase() - const requestedAt = new Date().toISOString() const user = await ctx.services.account(ctx.db).getAccount(did) if (!user) { throw new InvalidRequestError('user not found') } + const token = getRandomToken().toUpperCase() + const requestedAt = new Date().toISOString() await ctx.db.db .insertInto('delete_account_token') .values({ did, token, requestedAt }) diff --git a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..aa7b632569e --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,20 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.requestEmailConfirmation({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth }) => { + const did = auth.credentials.did + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found') + } + const token = await ctx.services + .account(ctx.db) + .createEmailToken(did, 'confirm_email') + await ctx.mailer.sendConfirmEmail({ token }, { to: user.email }) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..bcc65303f41 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,31 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.requestEmailUpdate({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth }) => { + const did = auth.credentials.did + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found') + } + + const tokenRequired = !!user.emailConfirmedAt + if (tokenRequired) { + const token = await ctx.services + .account(ctx.db) + .createEmailToken(did, 'update_email') + await ctx.mailer.sendUpdateEmail({ token }, { to: user.email }) + } + + return { + encoding: 'application/json', + body: { + tokenRequired, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/updateEmail.ts b/packages/pds/src/api/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..e5b013d8eba --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/updateEmail.ts @@ -0,0 +1,40 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.updateEmail({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth, input }) => { + const did = auth.credentials.did + const { token, email } = input.body + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found') + } + // require valid token + // @TODO re-enable updating non-verified emails + // if (user.emailConfirmedAt) { + if (!token) { + throw new InvalidRequestError( + 'confirmation token required', + 'TokenRequired', + ) + } + await ctx.services + .account(ctx.db) + .assertValidToken(did, 'update_email', token) + + await ctx.db.transaction(async (dbTxn) => { + const accntSrvce = ctx.services.account(dbTxn) + + if (token) { + await accntSrvce.deleteEmailToken(did, 'update_email') + } + if (user.email !== email) { + await accntSrvce.updateEmail(did, email) + } + }) + }, + }) +} diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index eda67a4e6fb..ee92742edff 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -15,6 +15,7 @@ import * as notification from './tables/user-notification' import * as blob from './tables/blob' import * as repoBlob from './tables/repo-blob' import * as deleteAccountToken from './tables/delete-account-token' +import * as emailToken from './tables/email-token' import * as moderation from './tables/moderation' import * as mute from './tables/mute' import * as listMute from './tables/list-mute' @@ -40,6 +41,7 @@ export type DatabaseSchemaType = runtimeFlag.PartialDB & blob.PartialDB & repoBlob.PartialDB & deleteAccountToken.PartialDB & + emailToken.PartialDB & moderation.PartialDB & mute.PartialDB & listMute.PartialDB & diff --git a/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts b/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts new file mode 100644 index 00000000000..ce8e6574731 --- /dev/null +++ b/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts @@ -0,0 +1,28 @@ +import { Kysely } from 'kysely' +import { Dialect } from '..' + +export async function up(db: Kysely, dialect: Dialect): Promise { + const timestamp = dialect === 'sqlite' ? 'datetime' : 'timestamptz' + await db.schema + .createTable('email_token') + .addColumn('purpose', 'varchar', (col) => col.notNull()) + .addColumn('did', 'varchar', (col) => col.notNull()) + .addColumn('token', 'varchar', (col) => col.notNull()) + .addColumn('requestedAt', timestamp, (col) => col.notNull()) + .addPrimaryKeyConstraint('email_token_pkey', ['purpose', 'did']) + .addUniqueConstraint('email_token_token_unique', ['purpose', 'token']) + .execute() + + await db.schema + .alterTable('user_account') + .addColumn('emailConfirmedAt', 'varchar') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('email_token').execute() + await db.schema + .alterTable('user_account') + .dropColumn('emailConfirmedAt') + .execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index fde3ddd2398..3636d304e46 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -66,3 +66,4 @@ export * as _20230824T182048120Z from './20230824T182048120Z-remove-post-hierarc export * as _20230825T142507884Z from './20230825T142507884Z-blob-tempkey-idx' export * as _20230828T153013575Z from './20230828T153013575Z-repo-history-rewrite' export * as _20230922T033938477Z from './20230922T033938477Z-remove-appview' +export * as _20230926T195532354Z from './20230926T195532354Z-email-tokens' diff --git a/packages/pds/src/db/tables/email-token.ts b/packages/pds/src/db/tables/email-token.ts new file mode 100644 index 00000000000..b8f42bde198 --- /dev/null +++ b/packages/pds/src/db/tables/email-token.ts @@ -0,0 +1,16 @@ +export type EmailTokenPurpose = + | 'confirm_email' + | 'update_email' + | 'reset_password' + | 'delete_account' + +export interface EmailToken { + purpose: EmailTokenPurpose + did: string + token: string + requestedAt: Date +} + +export const tableName = 'email_token' + +export type PartialDB = { [tableName]: EmailToken } diff --git a/packages/pds/src/db/tables/user-account.ts b/packages/pds/src/db/tables/user-account.ts index 665521efc08..ef9fdbecb3c 100644 --- a/packages/pds/src/db/tables/user-account.ts +++ b/packages/pds/src/db/tables/user-account.ts @@ -5,6 +5,7 @@ export interface UserAccount { email: string passwordScrypt: string createdAt: string + emailConfirmedAt: string | null passwordResetToken: string | null passwordResetGrantedAt: string | null invitesDisabled: Generated<0 | 1> diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 3dd2b5104cc..ac6ca933fcd 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -39,6 +39,7 @@ import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +import * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -52,9 +53,12 @@ import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +import * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +import * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +import * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -557,6 +561,17 @@ export class ServerNS { this._server = server } + confirmEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerConfirmEmail.Handler>, + ComAtprotoServerConfirmEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.confirmEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + createAccount( cfg: ConfigOf< AV, @@ -700,6 +715,28 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + requestEmailConfirmation( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailConfirmation.Handler>, + ComAtprotoServerRequestEmailConfirmation.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailConfirmation' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + requestEmailUpdate( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailUpdate.Handler>, + ComAtprotoServerRequestEmailUpdate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailUpdate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + requestPasswordReset( cfg: ConfigOf< AV, @@ -732,6 +769,17 @@ export class ServerNS { const nsid = 'com.atproto.server.revokeAppPassword' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + updateEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerUpdateEmail.Handler>, + ComAtprotoServerUpdateEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.updateEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class SyncNS { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 177b63808f4..77b4939d758 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2242,6 +2242,46 @@ export const schemaDict = { }, }, }, + ComAtprotoServerConfirmEmail: { + lexicon: 1, + id: 'com.atproto.server.confirmEmail', + defs: { + main: { + type: 'procedure', + description: + 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email', 'token'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + }, + }, + }, + }, + errors: [ + { + name: 'AccountNotFound', + }, + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'InvalidEmail', + }, + ], + }, + }, + }, ComAtprotoServerCreateAccount: { lexicon: 1, id: 'com.atproto.server.createAccount', @@ -2526,6 +2566,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2756,6 +2799,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2854,6 +2900,39 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRequestEmailConfirmation: { + lexicon: 1, + id: 'com.atproto.server.requestEmailConfirmation', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to confirm ownership of email', + }, + }, + }, + ComAtprotoServerRequestEmailUpdate: { + lexicon: 1, + id: 'com.atproto.server.requestEmailUpdate', + defs: { + main: { + type: 'procedure', + description: 'Request a token in order to update email.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['tokenRequired'], + properties: { + tokenRequired: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRequestPasswordReset: { lexicon: 1, id: 'com.atproto.server.requestPasswordReset', @@ -2931,6 +3010,44 @@ export const schemaDict = { }, }, }, + ComAtprotoServerUpdateEmail: { + lexicon: 1, + id: 'com.atproto.server.updateEmail', + defs: { + main: { + type: 'procedure', + description: "Update an account's email.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + description: + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", + }, + }, + }, + }, + errors: [ + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'TokenRequired', + }, + ], + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -7240,6 +7357,7 @@ export const ids = { ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + ComAtprotoServerConfirmEmail: 'com.atproto.server.confirmEmail', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', @@ -7256,10 +7374,14 @@ export const ids = { ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', + ComAtprotoServerRequestEmailConfirmation: + 'com.atproto.server.requestEmailConfirmation', + ComAtprotoServerRequestEmailUpdate: 'com.atproto.server.requestEmailUpdate', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', + ComAtprotoServerUpdateEmail: 'com.atproto.server.updateEmail', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/confirmEmail.ts b/packages/pds/src/lexicon/types/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..ffaeeb8fe75 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/confirmEmail.ts @@ -0,0 +1,40 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + token: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'AccountNotFound' | 'ExpiredToken' | 'InvalidToken' | 'InvalidEmail' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts index b836551f301..037900346a1 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts @@ -23,6 +23,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts index 388fb5eae9d..7f066a500bf 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts @@ -16,6 +16,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..e4244870425 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined +export type HandlerInput = undefined + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..6876d44ca46 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,43 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + tokenRequired: boolean + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts b/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..c88bd3021b2 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts @@ -0,0 +1,41 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ + token?: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'ExpiredToken' | 'InvalidToken' | 'TokenRequired' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/mailer/index.ts b/packages/pds/src/mailer/index.ts index 99059f6f02e..6c77fc8964c 100644 --- a/packages/pds/src/mailer/index.ts +++ b/packages/pds/src/mailer/index.ts @@ -24,6 +24,8 @@ export class ServerMailer { this.templates = { resetPassword: this.compile('reset-password'), deleteAccount: this.compile('delete-account'), + confirmEmail: this.compile('confirm-email'), + updateEmail: this.compile('update-email'), } } @@ -51,6 +53,20 @@ export class ServerMailer { }) } + async sendConfirmEmail(params: { token: string }, mailOpts: Mail.Options) { + return this.sendTemplate('confirmEmail', params, { + subject: 'Email Confirmation', + ...mailOpts, + }) + } + + async sendUpdateEmail(params: { token: string }, mailOpts: Mail.Options) { + return this.sendTemplate('updateEmail', params, { + subject: 'Email Update Requested', + ...mailOpts, + }) + } + private async sendTemplate(templateName, params, mailOpts: Mail.Options) { const html = this.templates[templateName]({ ...params, diff --git a/packages/pds/src/mailer/templates/confirm-email.hbs b/packages/pds/src/mailer/templates/confirm-email.hbs new file mode 100644 index 00000000000..ee062a40e07 --- /dev/null +++ b/packages/pds/src/mailer/templates/confirm-email.hbs @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/pds/src/mailer/templates/update-email.hbs b/packages/pds/src/mailer/templates/update-email.hbs new file mode 100644 index 00000000000..f49947125be --- /dev/null +++ b/packages/pds/src/mailer/templates/update-email.hbs @@ -0,0 +1,383 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 0cb293ff26c..33978be9b8b 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -1,4 +1,6 @@ import { sql } from 'kysely' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { MINUTE, lessThanAgoMs } from '@atproto/common' import { dbLogger as log } from '../../logger' import Database from '../../db' import * as scrypt from '../../db/scrypt' @@ -11,7 +13,8 @@ import { paginate, TimeCidKeyset } from '../../db/pagination' import * as sequencer from '../../sequencer' import { AppPassword } from '../../lexicon/types/com/atproto/server/createAppPassword' import { randomStr } from '@atproto/crypto' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { EmailTokenPurpose } from '../../db/tables/email-token' +import { getRandomToken } from '../../api/com/atproto/server/util' export class AccountService { constructor(public db: Database) {} @@ -197,7 +200,7 @@ export class AccountService { async updateEmail(did: string, email: string) { await this.db.db .updateTable('user_account') - .set({ email: email.toLowerCase() }) + .set({ email: email.toLowerCase(), emailConfirmedAt: null }) .where('did', '=', did) .executeTakeFirst() } @@ -529,6 +532,49 @@ export class AccountService { }, {} as Record) } + async createEmailToken( + did: string, + purpose: EmailTokenPurpose, + ): Promise { + const token = getRandomToken().toUpperCase() + await this.db.db + .insertInto('email_token') + .values({ purpose, did, token, requestedAt: new Date() }) + .onConflict((oc) => oc.columns(['purpose', 'did']).doUpdateSet({ token })) + .execute() + return token + } + + async deleteEmailToken(did: string, purpose: EmailTokenPurpose) { + await this.db.db + .deleteFrom('email_token') + .where('did', '=', did) + .where('purpose', '=', purpose) + .executeTakeFirst() + } + + async assertValidToken( + did: string, + purpose: EmailTokenPurpose, + token: string, + expirationLen = 15 * MINUTE, + ) { + const res = await this.db.db + .selectFrom('email_token') + .selectAll() + .where('purpose', '=', purpose) + .where('did', '=', did) + .where('token', '=', token) + .executeTakeFirst() + if (!res) { + throw new InvalidRequestError('Token is invalid', 'InvalidToken') + } + const expired = !lessThanAgoMs(res.requestedAt, expirationLen) + if (expired) { + throw new InvalidRequestError('Token is expired', 'ExpiredToken') + } + } + async getLastSeenNotifs(did: string): Promise { const res = await this.db.db .selectFrom('user_state') diff --git a/packages/pds/tests/account-deletion.test.ts b/packages/pds/tests/account-deletion.test.ts index d6b4aa101ce..6f7573bc423 100644 --- a/packages/pds/tests/account-deletion.test.ts +++ b/packages/pds/tests/account-deletion.test.ts @@ -68,6 +68,7 @@ describe('account deletion', () => { const getMailFrom = async (promise): Promise => { const result = await Promise.all([once(mailCatcher, 'mail'), promise]) + console.log(result) return result[0][0] } @@ -91,6 +92,7 @@ describe('account deletion', () => { return expect(token).toBeDefined() } }) + return it('fails account deletion with a bad token', async () => { const attempt = agent.api.com.atproto.server.deleteAccount({ diff --git a/packages/pds/tests/auth.test.ts b/packages/pds/tests/auth.test.ts index c1883d5a7f7..ae78f3d5619 100644 --- a/packages/pds/tests/auth.test.ts +++ b/packages/pds/tests/auth.test.ts @@ -68,6 +68,7 @@ describe('auth', () => { did: account.did, handle: account.handle, email, + emailConfirmed: false, }) // Valid refresh token const nextSession = await refreshSession(account.refreshJwt) @@ -96,6 +97,7 @@ describe('auth', () => { did: session.did, handle: session.handle, email, + emailConfirmed: false, }) // Valid refresh token const nextSession = await refreshSession(session.refreshJwt) @@ -139,6 +141,7 @@ describe('auth', () => { did: session.did, handle: session.handle, email, + emailConfirmed: false, }) // Valid refresh token const nextSession = await refreshSession(session.refreshJwt) diff --git a/packages/pds/tests/email-confirmation.test.ts b/packages/pds/tests/email-confirmation.test.ts new file mode 100644 index 00000000000..48a0c375510 --- /dev/null +++ b/packages/pds/tests/email-confirmation.test.ts @@ -0,0 +1,209 @@ +import { once, EventEmitter } from 'events' +import Mail from 'nodemailer/lib/mailer' +import AtpAgent from '@atproto/api' +import { SeedClient } from './seeds/client' +import userSeed from './seeds/users' +import { ServerMailer } from '../src/mailer' +import { TestNetworkNoAppView } from '@atproto/dev-env' +import { + ComAtprotoServerConfirmEmail, + ComAtprotoServerUpdateEmail, +} from '@atproto/api' + +describe('email confirmation', () => { + let network: TestNetworkNoAppView + let agent: AtpAgent + let sc: SeedClient + + let mailer: ServerMailer + const mailCatcher = new EventEmitter() + let _origSendMail + + let alice + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'email_confirmation', + }) + mailer = network.pds.ctx.mailer + agent = network.pds.getClient() + sc = new SeedClient(agent) + await userSeed(sc) + alice = sc.accounts[sc.dids.alice] + + // Catch emails for use in tests + _origSendMail = mailer.transporter.sendMail + mailer.transporter.sendMail = async (opts) => { + const result = await _origSendMail.call(mailer.transporter, opts) + mailCatcher.emit('mail', opts) + return result + } + }) + + afterAll(async () => { + mailer.transporter.sendMail = _origSendMail + await network.close() + }) + + const getMailFrom = async (promise): Promise => { + const result = await Promise.all([once(mailCatcher, 'mail'), promise]) + return result[0][0] + } + + const getTokenFromMail = (mail: Mail.Options) => + mail.html?.toString().match(/>([a-z0-9]{5}-[a-z0-9]{5}) { + const session = await agent.api.com.atproto.server.getSession( + {}, + { headers: sc.getHeaders(alice.did) }, + ) + expect(session.data.emailConfirmed).toEqual(false) + }) + + it('disallows email update without token when unverified', async () => { + const res = await agent.api.com.atproto.server.requestEmailUpdate( + undefined, + { headers: sc.getHeaders(alice.did) }, + ) + expect(res.data.tokenRequired).toBe(false) + + const attempt = agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice@example.com', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow() + + // await agent.api.com.atproto.server.updateEmail( + // { + // email: 'new-alice@example.com', + // }, + // { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + // ) + // const session = await agent.api.com.atproto.server.getSession( + // {}, + // { headers: sc.getHeaders(alice.did) }, + // ) + // expect(session.data.email).toEqual('new-alice@example.com') + // expect(session.data.emailConfirmed).toEqual(false) + // alice.email = session.data.email + }) + + let confirmToken + + it('requests email confirmation', async () => { + const mail = await getMailFrom( + agent.api.com.atproto.server.requestEmailConfirmation(undefined, { + headers: sc.getHeaders(alice.did), + }), + ) + expect(mail.to).toEqual(alice.email) + expect(mail.html).toContain('Confirm your Bluesky email') + confirmToken = getTokenFromMail(mail) + expect(confirmToken).toBeDefined() + }) + + it('fails email confirmation with a bad token', async () => { + const attempt = agent.api.com.atproto.server.confirmEmail( + { + email: alice.email, + token: '123456', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerConfirmEmail.InvalidTokenError, + ) + }) + + it('fails email confirmation with a bad token', async () => { + const attempt = agent.api.com.atproto.server.confirmEmail( + { + email: 'fake-alice@example.com', + token: confirmToken, + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerConfirmEmail.InvalidEmailError, + ) + }) + + it('confirms email', async () => { + await agent.api.com.atproto.server.confirmEmail( + { + email: alice.email, + token: confirmToken, + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + const session = await agent.api.com.atproto.server.getSession( + {}, + { headers: sc.getHeaders(alice.did) }, + ) + expect(session.data.emailConfirmed).toBe(true) + }) + + it('disallows email update without token when verified', async () => { + const attempt = agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice-2@example.com', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerUpdateEmail.TokenRequiredError, + ) + }) + + let updateToken + + it('requests email update', async () => { + const reqUpdate = async () => { + const res = await agent.api.com.atproto.server.requestEmailUpdate( + undefined, + { + headers: sc.getHeaders(alice.did), + }, + ) + expect(res.data.tokenRequired).toBe(true) + } + const mail = await getMailFrom(reqUpdate()) + expect(mail.to).toEqual(alice.email) + expect(mail.html).toContain('Update your Bluesky email') + updateToken = getTokenFromMail(mail) + expect(updateToken).toBeDefined() + }) + + it('fails email update with a bad token', async () => { + const attempt = agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice-2@example.com', + token: '123456', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerUpdateEmail.InvalidTokenError, + ) + }) + + it('updates email', async () => { + await agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice-2@example.com', + token: updateToken, + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + + const session = await agent.api.com.atproto.server.getSession( + {}, + { headers: sc.getHeaders(alice.did) }, + ) + expect(session.data.email).toBe('new-alice-2@example.com') + expect(session.data.emailConfirmed).toBe(false) + }) +})