From e54f23014f06285f512e53b8a4261cb6a3e416ea Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Tue, 26 Sep 2023 20:18:30 -0500 Subject: [PATCH 1/7] Check recent likes for suggested follows (#1680) check recent likes for suggested follows --- .../bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index be42ce2b959..eddf0cd5fd6 100644 --- a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -78,6 +78,7 @@ async function getSkeleton( .selectFrom('like') .where('creator', '=', params.actor) .select(sql`split_part(subject, '/', 3)`.as('subjectDid')) + .orderBy('sortAt', 'desc') .limit(1000) // limit to 1000 .as('likes'), ) From 41ee177f5a440490280d17acd8a89bcddaffb23b Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Wed, 27 Sep 2023 16:08:58 -0500 Subject: [PATCH 2/7] 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 fcb6fe7c26144662f791c7900afcd84c7bf1be6b) * 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) + }) +}) From 527fc58e6d5c6d456d860d487444ad37ed24df9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 Sep 2023 16:18:27 -0500 Subject: [PATCH 3/7] Version packages (#1685) Co-authored-by: github-actions[bot] --- .changeset/little-fans-obey.md | 7 ------- .changeset/witty-islands-burn.md | 5 ----- packages/api/CHANGELOG.md | 12 ++++++++++++ packages/api/package.json | 2 +- packages/aws/CHANGELOG.md | 7 +++++++ packages/aws/package.json | 2 +- packages/bsky/CHANGELOG.md | 13 +++++++++++++ packages/bsky/package.json | 2 +- packages/common-web/CHANGELOG.md | 7 +++++++ packages/common-web/package.json | 2 +- packages/common/CHANGELOG.md | 8 ++++++++ packages/common/package.json | 2 +- packages/dev-env/CHANGELOG.md | 15 +++++++++++++++ packages/dev-env/package.json | 2 +- packages/identity/CHANGELOG.md | 8 ++++++++ packages/identity/package.json | 2 +- packages/lex-cli/CHANGELOG.md | 8 ++++++++ packages/lex-cli/package.json | 2 +- packages/lexicon/CHANGELOG.md | 8 ++++++++ packages/lexicon/package.json | 2 +- packages/pds/CHANGELOG.md | 16 ++++++++++++++++ packages/pds/package.json | 2 +- packages/repo/CHANGELOG.md | 11 +++++++++++ packages/repo/package.json | 2 +- packages/syntax/CHANGELOG.md | 7 +++++++ packages/syntax/package.json | 2 +- packages/xrpc-server/CHANGELOG.md | 8 ++++++++ packages/xrpc-server/package.json | 2 +- packages/xrpc/CHANGELOG.md | 7 +++++++ packages/xrpc/package.json | 2 +- 30 files changed, 149 insertions(+), 26 deletions(-) delete mode 100644 .changeset/little-fans-obey.md delete mode 100644 .changeset/witty-islands-burn.md create mode 100644 packages/common-web/CHANGELOG.md create mode 100644 packages/common/CHANGELOG.md create mode 100644 packages/identity/CHANGELOG.md diff --git a/.changeset/little-fans-obey.md b/.changeset/little-fans-obey.md deleted file mode 100644 index 6fd0265b15b..00000000000 --- a/.changeset/little-fans-obey.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@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 deleted file mode 100644 index b82cd22b8c1..00000000000 --- a/.changeset/witty-islands-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/common-web': patch ---- - -Added lessThanAgoMs utility diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index 35fa620859e..2a42956bc31 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,17 @@ # @atproto/api +## 0.6.20 + +### Patch Changes + +- [#1568](https://github.com/bluesky-social/atproto/pull/1568) [`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b) Thanks [@dholms](https://github.com/dholms)! - Added email verification and update flows + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/common-web@0.2.1 + - @atproto/lexicon@0.2.2 + - @atproto/syntax@0.1.2 + - @atproto/xrpc@0.3.2 + ## 0.6.19 ### Patch Changes diff --git a/packages/api/package.json b/packages/api/package.json index d40f0dfd8c8..20780bfb888 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/api", - "version": "0.6.19", + "version": "0.6.20", "license": "MIT", "description": "Client library for atproto and Bluesky", "keywords": [ diff --git a/packages/aws/CHANGELOG.md b/packages/aws/CHANGELOG.md index a446e35adbe..49c88194d10 100644 --- a/packages/aws/CHANGELOG.md +++ b/packages/aws/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/aws +## 0.1.2 + +### Patch Changes + +- Updated dependencies []: + - @atproto/repo@0.3.2 + ## 0.1.1 ### Patch Changes diff --git a/packages/aws/package.json b/packages/aws/package.json index 3cd4dddcf2e..58bb98e0f80 100644 --- a/packages/aws/package.json +++ b/packages/aws/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/aws", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "description": "Shared AWS cloud API helpers for atproto services", "keywords": [ diff --git a/packages/bsky/CHANGELOG.md b/packages/bsky/CHANGELOG.md index f797a848ce9..13c794397af 100644 --- a/packages/bsky/CHANGELOG.md +++ b/packages/bsky/CHANGELOG.md @@ -1,5 +1,18 @@ # @atproto/bsky +## 0.0.11 + +### Patch Changes + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/api@0.6.20 + - @atproto/common@0.3.1 + - @atproto/identity@0.2.1 + - @atproto/lexicon@0.2.2 + - @atproto/repo@0.3.2 + - @atproto/syntax@0.1.2 + - @atproto/xrpc-server@0.3.2 + ## 0.0.10 ### Patch Changes diff --git a/packages/bsky/package.json b/packages/bsky/package.json index 1ea154701b5..03586043bcd 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsky", - "version": "0.0.10", + "version": "0.0.11", "license": "MIT", "description": "Reference implementation of app.bsky App View (Bluesky API)", "keywords": [ diff --git a/packages/common-web/CHANGELOG.md b/packages/common-web/CHANGELOG.md new file mode 100644 index 00000000000..ffe63bdf95d --- /dev/null +++ b/packages/common-web/CHANGELOG.md @@ -0,0 +1,7 @@ +# @atproto/common-web + +## 0.2.1 + +### Patch Changes + +- [#1568](https://github.com/bluesky-social/atproto/pull/1568) [`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b) Thanks [@dholms](https://github.com/dholms)! - Added lessThanAgoMs utility diff --git a/packages/common-web/package.json b/packages/common-web/package.json index ad5ee4d8ff3..9145c4d2f5f 100644 --- a/packages/common-web/package.json +++ b/packages/common-web/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/common-web", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "description": "Shared web-platform-friendly code for atproto libraries", "keywords": [ diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md new file mode 100644 index 00000000000..2bc819a30dc --- /dev/null +++ b/packages/common/CHANGELOG.md @@ -0,0 +1,8 @@ +# @atproto/common + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/common-web@0.2.1 diff --git a/packages/common/package.json b/packages/common/package.json index 5b88803035f..4803b9d0d19 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/common", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "description": "Shared web-platform-friendly code for atproto libraries", "keywords": [ diff --git a/packages/dev-env/CHANGELOG.md b/packages/dev-env/CHANGELOG.md index 1bc9601f4c9..fc9111c0a1a 100644 --- a/packages/dev-env/CHANGELOG.md +++ b/packages/dev-env/CHANGELOG.md @@ -1,5 +1,20 @@ # @atproto/dev-env +## 0.2.11 + +### Patch Changes + +- [#1568](https://github.com/bluesky-social/atproto/pull/1568) [`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b) Thanks [@dholms](https://github.com/dholms)! - Added email verification and update flows + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b), [`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/api@0.6.20 + - @atproto/pds@0.1.20 + - @atproto/common-web@0.2.1 + - @atproto/bsky@0.0.11 + - @atproto/identity@0.2.1 + - @atproto/syntax@0.1.2 + - @atproto/xrpc-server@0.3.2 + ## 0.2.10 ### Patch Changes diff --git a/packages/dev-env/package.json b/packages/dev-env/package.json index dde8dfb3e38..8af9282bbb2 100644 --- a/packages/dev-env/package.json +++ b/packages/dev-env/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/dev-env", - "version": "0.2.10", + "version": "0.2.11", "license": "MIT", "description": "Local development environment helper for atproto development", "keywords": [ diff --git a/packages/identity/CHANGELOG.md b/packages/identity/CHANGELOG.md new file mode 100644 index 00000000000..bcabd8e37e4 --- /dev/null +++ b/packages/identity/CHANGELOG.md @@ -0,0 +1,8 @@ +# @atproto/identity + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/common-web@0.2.1 diff --git a/packages/identity/package.json b/packages/identity/package.json index e8b2abcf454..59de4cc414c 100644 --- a/packages/identity/package.json +++ b/packages/identity/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/identity", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "description": "Library for decentralized identities in atproto using DIDs and handles", "keywords": [ diff --git a/packages/lex-cli/CHANGELOG.md b/packages/lex-cli/CHANGELOG.md index bc4a4c0ff3a..1faad66a912 100644 --- a/packages/lex-cli/CHANGELOG.md +++ b/packages/lex-cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @atproto/lex-cli +## 0.2.2 + +### Patch Changes + +- Updated dependencies []: + - @atproto/lexicon@0.2.2 + - @atproto/syntax@0.1.2 + ## 0.2.1 ### Patch Changes diff --git a/packages/lex-cli/package.json b/packages/lex-cli/package.json index db23ef2e1ce..2f2c34c0236 100644 --- a/packages/lex-cli/package.json +++ b/packages/lex-cli/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/lex-cli", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "description": "TypeScript codegen tool for atproto Lexicon schemas", "keywords": [ diff --git a/packages/lexicon/CHANGELOG.md b/packages/lexicon/CHANGELOG.md index 502d2142522..f2207b268a4 100644 --- a/packages/lexicon/CHANGELOG.md +++ b/packages/lexicon/CHANGELOG.md @@ -1,5 +1,13 @@ # @atproto/lexicon +## 0.2.2 + +### Patch Changes + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/common-web@0.2.1 + - @atproto/syntax@0.1.2 + ## 0.2.1 ### Patch Changes diff --git a/packages/lexicon/package.json b/packages/lexicon/package.json index ebce21d48af..e43c9c6e80a 100644 --- a/packages/lexicon/package.json +++ b/packages/lexicon/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/lexicon", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "description": "atproto Lexicon schema language library", "keywords": [ diff --git a/packages/pds/CHANGELOG.md b/packages/pds/CHANGELOG.md index 88c86c867d1..75d24e70991 100644 --- a/packages/pds/CHANGELOG.md +++ b/packages/pds/CHANGELOG.md @@ -1,5 +1,21 @@ # @atproto/pds +## 0.1.20 + +### Patch Changes + +- [#1568](https://github.com/bluesky-social/atproto/pull/1568) [`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b) Thanks [@dholms](https://github.com/dholms)! - Added email verification and update flows + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/api@0.6.20 + - @atproto/common@0.3.1 + - @atproto/identity@0.2.1 + - @atproto/lexicon@0.2.2 + - @atproto/repo@0.3.2 + - @atproto/syntax@0.1.2 + - @atproto/xrpc-server@0.3.2 + - @atproto/xrpc@0.3.2 + ## 0.1.19 ### Patch Changes diff --git a/packages/pds/package.json b/packages/pds/package.json index b439e1c72c5..b6cacf93672 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.1.19", + "version": "0.1.20", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ diff --git a/packages/repo/CHANGELOG.md b/packages/repo/CHANGELOG.md index 74852b0b0bb..40fff4f310d 100644 --- a/packages/repo/CHANGELOG.md +++ b/packages/repo/CHANGELOG.md @@ -1,5 +1,16 @@ # @atproto/repo +## 0.3.2 + +### Patch Changes + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/common-web@0.2.1 + - @atproto/common@0.3.1 + - @atproto/identity@0.2.1 + - @atproto/lexicon@0.2.2 + - @atproto/syntax@0.1.2 + ## 0.3.1 ### Patch Changes diff --git a/packages/repo/package.json b/packages/repo/package.json index bcb9d5eb452..1264656f4d4 100644 --- a/packages/repo/package.json +++ b/packages/repo/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/repo", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "description": "atproto repo and MST implementation", "keywords": [ diff --git a/packages/syntax/CHANGELOG.md b/packages/syntax/CHANGELOG.md index 99a4bd635f4..ed367a2f2ab 100644 --- a/packages/syntax/CHANGELOG.md +++ b/packages/syntax/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/syntax +## 0.1.2 + +### Patch Changes + +- Updated dependencies [[`41ee177f`](https://github.com/bluesky-social/atproto/commit/41ee177f5a440490280d17acd8a89bcddaffb23b)]: + - @atproto/common-web@0.2.1 + ## 0.1.1 ### Patch Changes diff --git a/packages/syntax/package.json b/packages/syntax/package.json index 54488519d50..c14254f04af 100644 --- a/packages/syntax/package.json +++ b/packages/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/syntax", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "description": "Validation for atproto identifiers and formats: DID, handle, NSID, AT URI, etc", "keywords": [ diff --git a/packages/xrpc-server/CHANGELOG.md b/packages/xrpc-server/CHANGELOG.md index 8f0d8d9093e..e3c8d9ae135 100644 --- a/packages/xrpc-server/CHANGELOG.md +++ b/packages/xrpc-server/CHANGELOG.md @@ -1,5 +1,13 @@ # @atproto/xrpc-server +## 0.3.2 + +### Patch Changes + +- Updated dependencies []: + - @atproto/common@0.3.1 + - @atproto/lexicon@0.2.2 + ## 0.3.1 ### Patch Changes diff --git a/packages/xrpc-server/package.json b/packages/xrpc-server/package.json index 2714be3951c..ef3c56b3bc0 100644 --- a/packages/xrpc-server/package.json +++ b/packages/xrpc-server/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/xrpc-server", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "description": "atproto HTTP API (XRPC) server library", "keywords": [ diff --git a/packages/xrpc/CHANGELOG.md b/packages/xrpc/CHANGELOG.md index 60e4ef37152..e12319fa88e 100644 --- a/packages/xrpc/CHANGELOG.md +++ b/packages/xrpc/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/xrpc +## 0.3.2 + +### Patch Changes + +- Updated dependencies []: + - @atproto/lexicon@0.2.2 + ## 0.3.1 ### Patch Changes diff --git a/packages/xrpc/package.json b/packages/xrpc/package.json index 68ee51b35b8..58ed98bd88f 100644 --- a/packages/xrpc/package.json +++ b/packages/xrpc/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/xrpc", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "description": "atproto HTTP API (XRPC) client library", "keywords": [ From 173f3bf5b89633b8a5cd85175a3f2469da89f6d3 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Wed, 27 Sep 2023 16:55:25 -0500 Subject: [PATCH 4/7] Bugfix: handle contention (#1629) bugfix: handle contention --- packages/bsky/src/services/indexing/index.ts | 32 +++++++++----------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/bsky/src/services/indexing/index.ts b/packages/bsky/src/services/indexing/index.ts index 03dce203f36..60c465dc0fc 100644 --- a/packages/bsky/src/services/indexing/index.ts +++ b/packages/bsky/src/services/indexing/index.ts @@ -144,24 +144,22 @@ export class IndexingService { const handle: string | null = did === handleToDid ? atpData.handle.toLowerCase() : null - if (actor && actor.handle !== handle) { - const actorWithHandle = - handle !== null - ? await this.db.db - .selectFrom('actor') - .where('handle', '=', handle) - .selectAll() - .executeTakeFirst() - : null + const actorWithHandle = + handle !== null + ? await this.db.db + .selectFrom('actor') + .where('handle', '=', handle) + .selectAll() + .executeTakeFirst() + : null - // handle contention - if (handle && actorWithHandle && did !== actorWithHandle.did) { - await this.db.db - .updateTable('actor') - .where('actor.did', '=', actorWithHandle.did) - .set({ handle: null }) - .execute() - } + // handle contention + if (handle && actorWithHandle && did !== actorWithHandle.did) { + await this.db.db + .updateTable('actor') + .where('actor.did', '=', actorWithHandle.did) + .set({ handle: null }) + .execute() } const actorInfo = { handle, indexedAt: timestamp } From 9b2ba28fff19430ff71c5278f100e06cc514ab8c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 28 Sep 2023 11:45:29 -0500 Subject: [PATCH 5/7] improve tag validation (#1684) * check faceted tags for slurs * format * fix baby bug --- packages/pds/src/repo/prepare.ts | 13 +++++++++- packages/pds/tests/create-post.test.ts | 35 +++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/repo/prepare.ts b/packages/pds/src/repo/prepare.ts index 2147ef552b6..88201455300 100644 --- a/packages/pds/src/repo/prepare.ts +++ b/packages/pds/src/repo/prepare.ts @@ -31,6 +31,7 @@ import { Record as PostRecord, isRecord as isPost, } from '../lexicon/types/app/bsky/feed/post' +import { isTag } from '../lexicon/types/app/bsky/richtext/facet' import { isRecord as isList } from '../lexicon/types/app/bsky/graph/list' import { isRecord as isProfile } from '../lexicon/types/app/bsky/actor/profile' import { hasExplicitSlur } from '../handle/explicit-slurs' @@ -300,7 +301,17 @@ function assertNoExplicitSlurs(rkey: string, record: RepoRecord) { toCheck += ' ' + rkey toCheck += ' ' + record.displayName } else if (isPost(record)) { - toCheck += record.tags?.join(' ') + if (record.tags) { + toCheck += record.tags.join(' ') + } + + for (const facet of record.facets || []) { + for (const feat of facet.features) { + if (isTag(feat)) { + toCheck += ' ' + feat.tag + } + } + } } if (hasExplicitSlur(toCheck)) { throw new InvalidRecordError('Unacceptable slur in record') diff --git a/packages/pds/tests/create-post.test.ts b/packages/pds/tests/create-post.test.ts index e2763981fb0..3a96d0f15a7 100644 --- a/packages/pds/tests/create-post.test.ts +++ b/packages/pds/tests/create-post.test.ts @@ -1,4 +1,9 @@ -import AtpAgent, { AppBskyFeedPost, AtUri } from '@atproto/api' +import AtpAgent, { + AppBskyFeedPost, + AtUri, + RichText, + AppBskyRichtextFacet, +} from '@atproto/api' import { runTestServer, TestServerInfo } from './_util' import { SeedClient } from './seeds/client' import basicSeed from './seeds/basic' @@ -42,4 +47,32 @@ describe('pds posts record creation', () => { expect(record).toBeTruthy() expect(record.tags).toEqual(['javascript', 'hehe']) }) + + it('handles RichText tag facets as well', async () => { + const rt = new RichText({ text: 'hello #world' }) + await rt.detectFacets(agent) + + const post: AppBskyFeedPost.Record = { + text: rt.text, + facets: rt.facets, + createdAt: new Date().toISOString(), + } + + const res = await agent.api.app.bsky.feed.post.create( + { repo: sc.dids.alice }, + post, + sc.getHeaders(sc.dids.alice), + ) + const { value: record } = await agent.api.app.bsky.feed.post.get({ + repo: sc.dids.alice, + rkey: new AtUri(res.uri).rkey, + }) + + expect(record).toBeTruthy() + expect( + record.facets?.every((f) => { + return AppBskyRichtextFacet.isTag(f.features[0]) + }), + ).toBeTruthy() + }) }) From 0fa34e481960f729e343f7c8ed85b33c15d3a0ef Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 28 Sep 2023 13:17:36 -0500 Subject: [PATCH 6/7] apply basic email validation to createAccount (#1658) * apply basic email validation to createAccount * format --- packages/pds/package.json | 2 ++ .../api/com/atproto/server/createAccount.ts | 7 ++++++ packages/pds/tests/account.test.ts | 22 +++++++++++++++++++ pnpm-lock.yaml | 14 ++++++++++++ 4 files changed, 45 insertions(+) diff --git a/packages/pds/package.json b/packages/pds/package.json index b6cacf93672..a8bf838afde 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -47,6 +47,7 @@ "bytes": "^3.1.2", "compression": "^1.7.4", "cors": "^2.8.5", + "disposable-email": "^0.2.3", "dotenv": "^16.0.0", "express": "^4.17.2", "express-async-errors": "^3.1.1", @@ -77,6 +78,7 @@ "@atproto/lex-cli": "workspace:^", "@did-plc/server": "^0.0.1", "@types/cors": "^2.8.12", + "@types/disposable-email": "^0.2.0", "@types/express": "^4.17.13", "@types/express-serve-static-core": "^4.17.36", "@types/jsonwebtoken": "^8.5.9", diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 5827ff6c658..42264453583 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,4 +1,5 @@ import { InvalidRequestError } from '@atproto/xrpc-server' +import disposable from 'disposable-email' import { normalizeAndValidateHandle } from '../../../../handle' import * as plc from '@did-plc/lib' import * as scrypt from '../../../../db/scrypt' @@ -27,6 +28,12 @@ export default function (server: Server, ctx: AppContext) { ) } + if (!disposable.validate(email)) { + throw new InvalidRequestError( + 'This email address is not supported, please use a different email.', + ) + } + // normalize & ensure valid handle const handle = await normalizeAndValidateHandle({ ctx, diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index 78a769b6e9f..26328966312 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -76,6 +76,28 @@ describe('account', () => { await expect(promise).rejects.toThrow('Input/handle must be a valid handle') }) + describe('email validation', () => { + it('succeeds on allowed emails', async () => { + const promise = agent.api.com.atproto.server.createAccount({ + email: 'ok-email@gmail.com', + handle: 'ok-email.test', + password: 'asdf', + }) + await expect(promise).resolves.toBeTruthy() + }) + + it('fails on disallowed emails', async () => { + const promise = agent.api.com.atproto.server.createAccount({ + email: 'bad-email@disposeamail.com', + handle: 'bad-email.test', + password: 'asdf', + }) + await expect(promise).rejects.toThrow( + 'This email address is not supported, please use a different email.', + ) + }) + }) + let did: string let jwt: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd9ef8cdbad..80681385373 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -504,6 +504,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 + disposable-email: + specifier: ^0.2.3 + version: 0.2.3 dotenv: specifier: ^16.0.0 version: 16.0.3 @@ -586,6 +589,9 @@ importers: '@types/cors': specifier: ^2.8.12 version: 2.8.12 + '@types/disposable-email': + specifier: ^0.2.0 + version: 0.2.0 '@types/express': specifier: ^4.17.13 version: 4.17.13 @@ -5295,6 +5301,10 @@ packages: resolution: {integrity: sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==} dev: true + /@types/disposable-email@0.2.0: + resolution: {integrity: sha512-i4fPC8+a5j7RlKVe9cOHz6cYVxkSFYuJ78GB3EJPMfBJURWmEsD4gb/gD48j7KAWe0M8ZvdJR6a6GaDohTYttw==} + dev: true + /@types/elliptic@6.4.14: resolution: {integrity: sha512-z4OBcDAU0GVwDTuwJzQCiL6188QvZMkvoERgcVjq0/mPM8jCfdwZ3x5zQEVoL9WCAru3aG5wl3Z5Ww5wBWn7ZQ==} dependencies: @@ -6647,6 +6657,10 @@ packages: path-type: 4.0.0 dev: true + /disposable-email@0.2.3: + resolution: {integrity: sha512-gkBQQ5Res431ZXqLlAafrXHizG7/1FWmi8U2RTtriD78Vc10HhBUvdJun3R4eSF0KRIQQJs+wHlxjkED/Hr1EQ==} + dev: false + /doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} From 11e3b329e368790e2c9babfa4c933cbbc68f5b3a Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Thu, 28 Sep 2023 13:25:56 -0500 Subject: [PATCH 7/7] Clean up other email tokens (#1572) * lexicons * codegen * email templates * request routes * impl * migration * tidy * tests * tidy & bugfixes * format * fix api test * fix auth test * impl * update constraint name * temporarily disable unconfirmed updates * tidy * fix some tests * validate email syntax --- packages/bsky/tests/indexing.test.ts | 3 +- .../api/com/atproto/server/deleteAccount.ts | 66 ++---------------- .../atproto/server/requestAccountDelete.ts | 13 +--- .../atproto/server/requestPasswordReset.ts | 14 +--- .../api/com/atproto/server/resetPassword.ts | 68 ++----------------- .../src/api/com/atproto/server/updateEmail.ts | 29 +++++--- packages/pds/src/db/database-schema.ts | 2 - .../20230926T195532354Z-email-tokens.ts | 5 +- ...230927T212334019Z-simplify-email-tokens.ts | 34 ++++++++++ packages/pds/src/db/migrations/index.ts | 1 + .../pds/src/db/tables/delete-account-token.ts | 9 --- packages/pds/src/db/tables/user-account.ts | 2 - packages/pds/src/services/account/index.ts | 23 ++++++- packages/pds/tests/account.test.ts | 17 +++-- packages/pds/tests/email-confirmation.test.ts | 57 ++++++++++++---- packages/pds/tests/moderation.test.ts | 3 +- 16 files changed, 153 insertions(+), 193 deletions(-) create mode 100644 packages/pds/src/db/migrations/20230927T212334019Z-simplify-email-tokens.ts delete mode 100644 packages/pds/src/db/tables/delete-account-token.ts diff --git a/packages/bsky/tests/indexing.test.ts b/packages/bsky/tests/indexing.test.ts index cee3ed5a768..f5c8083df09 100644 --- a/packages/bsky/tests/indexing.test.ts +++ b/packages/bsky/tests/indexing.test.ts @@ -648,8 +648,9 @@ describe('indexing', () => { headers: sc.getHeaders(alice), }) const { token } = await network.pds.ctx.db.db - .selectFrom('delete_account_token') + .selectFrom('email_token') .selectAll() + .where('purpose', '=', 'delete_account') .where('did', '=', alice) .executeTakeFirstOrThrow() await pdsAgent.api.com.atproto.server.deleteAccount({ diff --git a/packages/pds/src/api/com/atproto/server/deleteAccount.ts b/packages/pds/src/api/com/atproto/server/deleteAccount.ts index 9ebfcfa7fdf..4d12edb1b32 100644 --- a/packages/pds/src/api/com/atproto/server/deleteAccount.ts +++ b/packages/pds/src/api/com/atproto/server/deleteAccount.ts @@ -2,7 +2,6 @@ import { AuthRequiredError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { TAKEDOWN } from '../../../../lexicon/types/com/atproto/admin/defs' import AppContext from '../../../../context' -import Database from '../../../../db' import { MINUTE } from '@atproto/common' const REASON_ACCT_DELETION = 'ACCOUNT DELETION' @@ -22,40 +21,18 @@ export default function (server: Server, ctx: AppContext) { throw new AuthRequiredError('Invalid did or password') } - const tokenInfo = await ctx.db.db - .selectFrom('did_handle') - .innerJoin( - 'delete_account_token as token', - 'token.did', - 'did_handle.did', - ) - .where('did_handle.did', '=', did) - .where('token.token', '=', token.toUpperCase()) - .select([ - 'token.token as token', - 'token.requestedAt as requestedAt', - 'token.did as did', - ]) - .executeTakeFirst() - - if (!tokenInfo) { - return createInvalidTokenError() - } + await ctx.services + .account(ctx.db) + .assertValidToken(did, 'delete_account', token) const now = new Date() - const requestedAt = new Date(tokenInfo.requestedAt) - const expiresAt = new Date(requestedAt.getTime() + 15 * minsToMs) - if (now > expiresAt) { - await removeDeleteToken(ctx.db, tokenInfo.did) - return createExpiredTokenError() - } - await ctx.db.transaction(async (dbTxn) => { + const accountService = ctx.services.account(dbTxn) const moderationTxn = ctx.services.moderation(dbTxn) const [currentAction] = await moderationTxn.getCurrentActions({ did }) if (currentAction?.action === TAKEDOWN) { // Do not disturb an existing takedown, continue with account deletion - return await removeDeleteToken(dbTxn, did) + return await accountService.deleteEmailToken(did, 'delete_account') } if (currentAction) { // Reverse existing action to replace it with a self-takedown @@ -74,7 +51,7 @@ export default function (server: Server, ctx: AppContext) { createdAt: now, }) await moderationTxn.takedownRepo({ did, takedownId: takedown.id }) - await removeDeleteToken(dbTxn, did) + await accountService.deleteEmailToken(did, 'delete_account') }) ctx.backgroundQueue.add(async (db) => { @@ -90,34 +67,3 @@ export default function (server: Server, ctx: AppContext) { }, }) } - -type ErrorResponse = { - status: number - error: string - message: string -} - -const minsToMs = 60 * 1000 - -const createInvalidTokenError = (): ErrorResponse & { - error: 'InvalidToken' -} => ({ - status: 400, - error: 'InvalidToken', - message: 'Token is invalid', -}) - -const createExpiredTokenError = (): ErrorResponse & { - error: 'ExpiredToken' -} => ({ - status: 400, - error: 'ExpiredToken', - message: 'The password reset token has expired', -}) - -const removeDeleteToken = async (db: Database, did: string) => { - await db.db - .deleteFrom('delete_account_token') - .where('delete_account_token.did', '=', did) - .execute() -} diff --git a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts index a448d97c02e..c438c32f69f 100644 --- a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts +++ b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts @@ -1,7 +1,6 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { getRandomToken } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.requestAccountDelete({ @@ -12,15 +11,9 @@ export default function (server: Server, ctx: AppContext) { 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 }) - .onConflict((oc) => - oc.column('did').doUpdateSet({ token, requestedAt }), - ) - .execute() + const token = await ctx.services + .account(ctx.db) + .createEmailToken(did, 'delete_account') await ctx.mailer.sendAccountDelete({ token }, { to: user.email }) }, }) diff --git a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts index 5d81e43c68b..61b17ebb9a9 100644 --- a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts +++ b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts @@ -1,6 +1,5 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' -import { getRandomToken } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.requestPasswordReset(async ({ input }) => { @@ -9,16 +8,9 @@ export default function (server: Server, ctx: AppContext) { const user = await ctx.services.account(ctx.db).getAccountByEmail(email) if (user) { - const token = getRandomToken().toUpperCase() - const grantedAt = new Date().toISOString() - await ctx.db.db - .updateTable('user_account') - .where('did', '=', user.did) - .set({ - passwordResetToken: token, - passwordResetGrantedAt: grantedAt, - }) - .execute() + const token = await ctx.services + .account(ctx.db) + .createEmailToken(user.did, 'reset_password') await ctx.mailer.sendResetPassword( { handle: user.handle, token }, { to: user.email }, diff --git a/packages/pds/src/api/com/atproto/server/resetPassword.ts b/packages/pds/src/api/com/atproto/server/resetPassword.ts index de8d10382c0..a84b6249a3c 100644 --- a/packages/pds/src/api/com/atproto/server/resetPassword.ts +++ b/packages/pds/src/api/com/atproto/server/resetPassword.ts @@ -1,6 +1,5 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' -import Database from '../../../../db' import { MINUTE } from '@atproto/common' export default function (server: Server, ctx: AppContext) { @@ -14,69 +13,16 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input }) => { const { token, password } = input.body - const tokenInfo = await ctx.db.db - .selectFrom('user_account') - .select(['did', 'passwordResetGrantedAt']) - .where('passwordResetToken', '=', token.toUpperCase()) - .executeTakeFirst() - - if (!tokenInfo?.passwordResetGrantedAt) { - return createInvalidTokenError() - } - - const now = new Date() - const grantedAt = new Date(tokenInfo.passwordResetGrantedAt) - const expiresAt = new Date(grantedAt.getTime() + 15 * minsToMs) - - if (now > expiresAt) { - await unsetResetToken(ctx.db, tokenInfo.did) - return createExpiredTokenError() - } + const did = await ctx.services + .account(ctx.db) + .assertValidTokenAndFindDid('reset_password', token) await ctx.db.transaction(async (dbTxn) => { - await unsetResetToken(dbTxn, tokenInfo.did) - await ctx.services - .account(dbTxn) - .updateUserPassword(tokenInfo.did, password) - await await ctx.services - .auth(dbTxn) - .revokeRefreshTokensByDid(tokenInfo.did) + const accountService = ctx.services.account(ctx.db) + await accountService.updateUserPassword(did, password) + await accountService.deleteEmailToken(did, 'reset_password') + await ctx.services.auth(dbTxn).revokeRefreshTokensByDid(did) }) }, }) } - -type ErrorResponse = { - status: number - error: string - message: string -} - -const minsToMs = 60 * 1000 - -const createInvalidTokenError = (): ErrorResponse & { - error: 'InvalidToken' -} => ({ - status: 400, - error: 'InvalidToken', - message: 'Token is invalid', -}) - -const createExpiredTokenError = (): ErrorResponse & { - error: 'ExpiredToken' -} => ({ - status: 400, - error: 'ExpiredToken', - message: 'The password reset token has expired', -}) - -const unsetResetToken = async (db: Database, did: string) => { - await db.db - .updateTable('user_account') - .where('did', '=', did) - .set({ - passwordResetToken: null, - passwordResetGrantedAt: null, - }) - .execute() -} diff --git a/packages/pds/src/api/com/atproto/server/updateEmail.ts b/packages/pds/src/api/com/atproto/server/updateEmail.ts index e5b013d8eba..1873f5e0157 100644 --- a/packages/pds/src/api/com/atproto/server/updateEmail.ts +++ b/packages/pds/src/api/com/atproto/server/updateEmail.ts @@ -1,6 +1,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' +import disposable from 'disposable-email' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.updateEmail({ @@ -8,22 +9,30 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ auth, input }) => { const did = auth.credentials.did const { token, email } = input.body + if (!disposable.validate(email)) { + throw new InvalidRequestError( + 'This email address is not supported, please use a different email.', + ) + } const user = await ctx.services.account(ctx.db).getAccount(did) if (!user) { throw new InvalidRequestError('user not found') } + if (!user.emailConfirmedAt) { + throw new InvalidRequestError('email must be confirmed (temporary)') + } // require valid token - // @TODO re-enable updating non-verified emails - // if (user.emailConfirmedAt) { - if (!token) { - throw new InvalidRequestError( - 'confirmation token required', - 'TokenRequired', - ) + 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.services - .account(ctx.db) - .assertValidToken(did, 'update_email', token) await ctx.db.transaction(async (dbTxn) => { const accntSrvce = ctx.services.account(dbTxn) diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index ee92742edff..a3fe7ad8799 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -14,7 +14,6 @@ import * as inviteCode from './tables/invite-code' 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' @@ -40,7 +39,6 @@ export type DatabaseSchemaType = runtimeFlag.PartialDB & notification.PartialDB & blob.PartialDB & repoBlob.PartialDB & - deleteAccountToken.PartialDB & emailToken.PartialDB & moderation.PartialDB & mute.PartialDB & diff --git a/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts b/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts index ce8e6574731..4200e64477b 100644 --- a/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts +++ b/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts @@ -10,7 +10,10 @@ export async function up(db: Kysely, dialect: Dialect): Promise { .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']) + .addUniqueConstraint('email_token_purpose_token_unique', [ + 'purpose', + 'token', + ]) .execute() await db.schema diff --git a/packages/pds/src/db/migrations/20230927T212334019Z-simplify-email-tokens.ts b/packages/pds/src/db/migrations/20230927T212334019Z-simplify-email-tokens.ts new file mode 100644 index 00000000000..3724de1cf56 --- /dev/null +++ b/packages/pds/src/db/migrations/20230927T212334019Z-simplify-email-tokens.ts @@ -0,0 +1,34 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('user_account') + .dropColumn('passwordResetToken') + .execute() + + await db.schema + .alterTable('user_account') + .dropColumn('passwordResetGrantedAt') + .execute() + + await db.schema.dropTable('delete_account_token').execute() +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('user_account') + .addColumn('passwordResetToken', 'varchar') + .execute() + + await db.schema + .alterTable('user_account') + .addColumn('passwordResetGrantedAt', 'varchar') + .execute() + + await db.schema + .createTable('delete_account_token') + .addColumn('did', 'varchar', (col) => col.primaryKey()) + .addColumn('token', 'varchar', (col) => col.notNull()) + .addColumn('requestedAt', 'varchar', (col) => col.notNull()) + .execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index 3636d304e46..e177084ff86 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -67,3 +67,4 @@ 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' +export * as _20230927T212334019Z from './20230927T212334019Z-simplify-email-tokens' diff --git a/packages/pds/src/db/tables/delete-account-token.ts b/packages/pds/src/db/tables/delete-account-token.ts deleted file mode 100644 index da748c639a7..00000000000 --- a/packages/pds/src/db/tables/delete-account-token.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface DeleteAccountToken { - did: string - token: string - requestedAt: string -} - -export const tableName = 'delete_account_token' - -export type PartialDB = { [tableName]: DeleteAccountToken } diff --git a/packages/pds/src/db/tables/user-account.ts b/packages/pds/src/db/tables/user-account.ts index ef9fdbecb3c..808663ca468 100644 --- a/packages/pds/src/db/tables/user-account.ts +++ b/packages/pds/src/db/tables/user-account.ts @@ -6,8 +6,6 @@ export interface UserAccount { passwordScrypt: string createdAt: string emailConfirmedAt: string | null - passwordResetToken: string | null - passwordResetGrantedAt: string | null invitesDisabled: Generated<0 | 1> inviteNote: string | null } diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 33978be9b8b..04561737144 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -564,7 +564,7 @@ export class AccountService { .selectAll() .where('purpose', '=', purpose) .where('did', '=', did) - .where('token', '=', token) + .where('token', '=', token.toUpperCase()) .executeTakeFirst() if (!res) { throw new InvalidRequestError('Token is invalid', 'InvalidToken') @@ -575,6 +575,27 @@ export class AccountService { } } + async assertValidTokenAndFindDid( + purpose: EmailTokenPurpose, + token: string, + expirationLen = 15 * MINUTE, + ): Promise { + const res = await this.db.db + .selectFrom('email_token') + .selectAll() + .where('purpose', '=', purpose) + .where('token', '=', token.toUpperCase()) + .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') + } + return res.did + } + async getLastSeenNotifs(did: string): Promise { const res = await this.db.db .selectFrom('user_state') diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index 26328966312..d2ca47e7a83 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -528,24 +528,23 @@ describe('account', () => { it('allows only unexpired password reset tokens', async () => { await agent.api.com.atproto.server.requestPasswordReset({ email }) - const user = await db.db - .updateTable('user_account') - .where('email', '=', email) + const res = await db.db + .updateTable('email_token') + .where('purpose', '=', 'reset_password') + .where('did', '=', did) .set({ - passwordResetGrantedAt: new Date( - Date.now() - 16 * minsToMs, - ).toISOString(), + requestedAt: new Date(Date.now() - 16 * minsToMs), }) - .returning(['passwordResetToken']) + .returning(['token']) .executeTakeFirst() - if (!user?.passwordResetToken) { + if (!res?.token) { throw new Error('Missing reset token') } // Use of expired token fails await expect( agent.api.com.atproto.server.resetPassword({ - token: user.passwordResetToken, + token: res.token, password: passwordAlt, }), ).rejects.toThrow(ComAtprotoServerResetPassword.ExpiredTokenError) diff --git a/packages/pds/tests/email-confirmation.test.ts b/packages/pds/tests/email-confirmation.test.ts index 48a0c375510..fc3c4caadcd 100644 --- a/packages/pds/tests/email-confirmation.test.ts +++ b/packages/pds/tests/email-confirmation.test.ts @@ -61,7 +61,7 @@ describe('email confirmation', () => { expect(session.data.emailConfirmed).toEqual(false) }) - it('disallows email update without token when unverified', async () => { + it('disallows email update when unverified', async () => { const res = await agent.api.com.atproto.server.requestEmailUpdate( undefined, { headers: sc.getHeaders(alice.did) }, @@ -75,22 +75,36 @@ describe('email confirmation', () => { { 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 + const session = await agent.api.com.atproto.server.getSession( + {}, + { headers: sc.getHeaders(alice.did) }, + ) + expect(session.data.email).toEqual(alice.email) + expect(session.data.emailConfirmed).toEqual(false) }) + // it('allows 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) + + // 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 () => { @@ -190,6 +204,19 @@ describe('email confirmation', () => { ) }) + it('fails email update with a badly formatted email', async () => { + const attempt = agent.api.com.atproto.server.updateEmail( + { + email: 'bad-email@disposeamail.com', + token: updateToken, + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + 'This email address is not supported, please use a different email.', + ) + }) + it('updates email', async () => { await agent.api.com.atproto.server.updateEmail( { diff --git a/packages/pds/tests/moderation.test.ts b/packages/pds/tests/moderation.test.ts index edbb23c6578..80536a6933f 100644 --- a/packages/pds/tests/moderation.test.ts +++ b/packages/pds/tests/moderation.test.ts @@ -286,7 +286,8 @@ describe('moderation', () => { headers: sc.getHeaders(deleteme.did), }) const { token: deletionToken } = await server.ctx.db.db - .selectFrom('delete_account_token') + .selectFrom('email_token') + .where('purpose', '=', 'delete_account') .where('did', '=', deleteme.did) .selectAll() .executeTakeFirstOrThrow()