diff --git a/.github/workflows/build-and-push-pds-ghcr.yaml b/.github/workflows/build-and-push-pds-ghcr.yaml index 422894a1bd2..b11230ab531 100644 --- a/.github/workflows/build-and-push-pds-ghcr.yaml +++ b/.github/workflows/build-and-push-pds-ghcr.yaml @@ -3,7 +3,6 @@ on: push: branches: - main - - pds-sanity-check env: REGISTRY: ghcr.io USERNAME: ${{ github.actor }} diff --git a/lexicons/com/atproto/temp/importRepo.json b/lexicons/com/atproto/temp/importRepo.json deleted file mode 100644 index f06daa09d73..00000000000 --- a/lexicons/com/atproto/temp/importRepo.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.importRepo", - "defs": { - "main": { - "type": "procedure", - "description": "Gets the did's repo, optionally catching up from a specific revision.", - "parameters": { - "type": "params", - "required": ["did"], - "properties": { - "did": { - "type": "string", - "format": "did", - "description": "The DID of the repo." - } - } - }, - "input": { - "encoding": "application/vnd.ipld.car" - }, - "output": { - "encoding": "text/plain" - } - } - } -} diff --git a/lexicons/com/atproto/temp/pushBlob.json b/lexicons/com/atproto/temp/pushBlob.json deleted file mode 100644 index 9babc8f8e43..00000000000 --- a/lexicons/com/atproto/temp/pushBlob.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.pushBlob", - "defs": { - "main": { - "type": "procedure", - "description": "Gets the did's repo, optionally catching up from a specific revision.", - "parameters": { - "type": "params", - "required": ["did"], - "properties": { - "did": { - "type": "string", - "format": "did", - "description": "The DID of the repo." - } - } - }, - "input": { - "encoding": "*/*" - } - } - } -} diff --git a/lexicons/com/atproto/temp/transferAccount.json b/lexicons/com/atproto/temp/transferAccount.json deleted file mode 100644 index d4687d9b6e1..00000000000 --- a/lexicons/com/atproto/temp/transferAccount.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.transferAccount", - "defs": { - "main": { - "type": "procedure", - "description": "Transfer an account. NOTE: temporary method, necessarily how account migration will be implemented.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["handle", "did", "plcOp"], - "properties": { - "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" }, - "plcOp": { "type": "unknown" } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["accessJwt", "refreshJwt", "handle", "did"], - "properties": { - "accessJwt": { "type": "string" }, - "refreshJwt": { "type": "string" }, - "handle": { "type": "string", "format": "handle" }, - "did": { "type": "string", "format": "did" } - } - } - }, - "errors": [ - { "name": "InvalidHandle" }, - { "name": "InvalidPassword" }, - { "name": "InvalidInviteCode" }, - { "name": "HandleNotAvailable" }, - { "name": "UnsupportedDomain" }, - { "name": "UnresolvableDid" }, - { "name": "IncompatibleDidDoc" } - ] - } - } -} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 42f31866dae..5029db88701 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -93,10 +93,7 @@ import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCra import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' import * as ComAtprotoTempCheckSignupQueue from './types/com/atproto/temp/checkSignupQueue' import * as ComAtprotoTempFetchLabels from './types/com/atproto/temp/fetchLabels' -import * as ComAtprotoTempImportRepo from './types/com/atproto/temp/importRepo' -import * as ComAtprotoTempPushBlob from './types/com/atproto/temp/pushBlob' import * as ComAtprotoTempRequestPhoneVerification from './types/com/atproto/temp/requestPhoneVerification' -import * as ComAtprotoTempTransferAccount from './types/com/atproto/temp/transferAccount' import * as AppBskyActorDefs from './types/app/bsky/actor/defs' import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences' import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile' @@ -249,10 +246,7 @@ export * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCra export * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' export * as ComAtprotoTempCheckSignupQueue from './types/com/atproto/temp/checkSignupQueue' export * as ComAtprotoTempFetchLabels from './types/com/atproto/temp/fetchLabels' -export * as ComAtprotoTempImportRepo from './types/com/atproto/temp/importRepo' -export * as ComAtprotoTempPushBlob from './types/com/atproto/temp/pushBlob' export * as ComAtprotoTempRequestPhoneVerification from './types/com/atproto/temp/requestPhoneVerification' -export * as ComAtprotoTempTransferAccount from './types/com/atproto/temp/transferAccount' export * as AppBskyActorDefs from './types/app/bsky/actor/defs' export * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences' export * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile' @@ -1369,28 +1363,6 @@ export class ComAtprotoTempNS { }) } - importRepo( - data?: ComAtprotoTempImportRepo.InputSchema, - opts?: ComAtprotoTempImportRepo.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.temp.importRepo', opts?.qp, data, opts) - .catch((e) => { - throw ComAtprotoTempImportRepo.toKnownErr(e) - }) - } - - pushBlob( - data?: ComAtprotoTempPushBlob.InputSchema, - opts?: ComAtprotoTempPushBlob.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.temp.pushBlob', opts?.qp, data, opts) - .catch((e) => { - throw ComAtprotoTempPushBlob.toKnownErr(e) - }) - } - requestPhoneVerification( data?: ComAtprotoTempRequestPhoneVerification.InputSchema, opts?: ComAtprotoTempRequestPhoneVerification.CallOptions, @@ -1401,17 +1373,6 @@ export class ComAtprotoTempNS { throw ComAtprotoTempRequestPhoneVerification.toKnownErr(e) }) } - - transferAccount( - data?: ComAtprotoTempTransferAccount.InputSchema, - opts?: ComAtprotoTempTransferAccount.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.temp.transferAccount', opts?.qp, data, opts) - .catch((e) => { - throw ComAtprotoTempTransferAccount.toKnownErr(e) - }) - } } export class AppNS { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index a3491462d50..6164b1706a3 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -4853,59 +4853,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempImportRepo: { - lexicon: 1, - id: 'com.atproto.temp.importRepo', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: 'application/vnd.ipld.car', - }, - output: { - encoding: 'text/plain', - }, - }, - }, - }, - ComAtprotoTempPushBlob: { - lexicon: 1, - id: 'com.atproto.temp.pushBlob', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: '*/*', - }, - }, - }, - }, ComAtprotoTempRequestPhoneVerification: { lexicon: 1, id: 'com.atproto.temp.requestPhoneVerification', @@ -4929,83 +4876,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempTransferAccount: { - lexicon: 1, - id: 'com.atproto.temp.transferAccount', - defs: { - main: { - type: 'procedure', - description: - 'Transfer an account. NOTE: temporary method, necessarily how account migration will be implemented.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did', 'plcOp'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - plcOp: { - type: 'unknown', - }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], - properties: { - accessJwt: { - type: 'string', - }, - refreshJwt: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - }, - }, - }, - errors: [ - { - name: 'InvalidHandle', - }, - { - name: 'InvalidPassword', - }, - { - name: 'InvalidInviteCode', - }, - { - name: 'HandleNotAvailable', - }, - { - name: 'UnsupportedDomain', - }, - { - name: 'UnresolvableDid', - }, - { - name: 'IncompatibleDidDoc', - }, - ], - }, - }, - }, AppBskyActorDefs: { lexicon: 1, id: 'app.bsky.actor.defs', @@ -8984,11 +8854,8 @@ export const ids = { ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempCheckSignupQueue: 'com.atproto.temp.checkSignupQueue', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', - ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', - ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', - ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', AppBskyActorGetProfile: 'app.bsky.actor.getProfile', diff --git a/packages/api/src/client/types/com/atproto/temp/importRepo.ts b/packages/api/src/client/types/com/atproto/temp/importRepo.ts deleted file mode 100644 index 6f9f99f2b9d..00000000000 --- a/packages/api/src/client/types/com/atproto/temp/importRepo.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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 { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface CallOptions { - headers?: Headers - qp?: QueryParams - encoding: 'application/vnd.ipld.car' -} - -export interface Response { - success: boolean - headers: Headers - data: Uint8Array -} - -export function toKnownErr(e: any) { - if (e instanceof XRPCError) { - } - return e -} diff --git a/packages/api/src/client/types/com/atproto/temp/pushBlob.ts b/packages/api/src/client/types/com/atproto/temp/pushBlob.ts deleted file mode 100644 index 32165bc8014..00000000000 --- a/packages/api/src/client/types/com/atproto/temp/pushBlob.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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 { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface CallOptions { - headers?: Headers - qp?: QueryParams - encoding: string -} - -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/temp/transferAccount.ts b/packages/api/src/client/types/com/atproto/temp/transferAccount.ts deleted file mode 100644 index 7ae16c01290..00000000000 --- a/packages/api/src/client/types/com/atproto/temp/transferAccount.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * 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 { - handle: string - did: string - plcOp: {} - [k: string]: unknown -} - -export interface OutputSchema { - accessJwt: string - refreshJwt: string - handle: string - did: string - [k: string]: unknown -} - -export interface CallOptions { - headers?: Headers - qp?: QueryParams - encoding: 'application/json' -} - -export interface Response { - success: boolean - headers: Headers - data: OutputSchema -} - -export class InvalidHandleError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class InvalidPasswordError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class InvalidInviteCodeError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class HandleNotAvailableError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class UnsupportedDomainError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class UnresolvableDidError extends XRPCError { - constructor(src: XRPCError) { - super(src.status, src.error, src.message, src.headers) - } -} - -export class IncompatibleDidDocError 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 === 'InvalidHandle') return new InvalidHandleError(e) - if (e.error === 'InvalidPassword') return new InvalidPasswordError(e) - if (e.error === 'InvalidInviteCode') return new InvalidInviteCodeError(e) - if (e.error === 'HandleNotAvailable') return new HandleNotAvailableError(e) - if (e.error === 'UnsupportedDomain') return new UnsupportedDomainError(e) - if (e.error === 'UnresolvableDid') return new UnresolvableDidError(e) - if (e.error === 'IncompatibleDidDoc') return new IncompatibleDidDocError(e) - } - return e -} diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index cff4e3517a8..7bebb8a1bcf 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -439,6 +439,7 @@ describe('agent', () => { expect(originalHandlerCallCount).toEqual(1) agent.setPersistSessionHandler(newPersistSession) + agent.session = undefined await agent.createAccount({ handle: 'user8.test', diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index ed08ff55702..6b3b6de582a 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -90,10 +90,7 @@ import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCra import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' import * as ComAtprotoTempCheckSignupQueue from './types/com/atproto/temp/checkSignupQueue' import * as ComAtprotoTempFetchLabels from './types/com/atproto/temp/fetchLabels' -import * as ComAtprotoTempImportRepo from './types/com/atproto/temp/importRepo' -import * as ComAtprotoTempPushBlob from './types/com/atproto/temp/pushBlob' import * as ComAtprotoTempRequestPhoneVerification from './types/com/atproto/temp/requestPhoneVerification' -import * as ComAtprotoTempTransferAccount from './types/com/atproto/temp/transferAccount' import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences' import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile' import * as AppBskyActorGetProfiles from './types/app/bsky/actor/getProfiles' @@ -1167,28 +1164,6 @@ export class ComAtprotoTempNS { return this._server.xrpc.method(nsid, cfg) } - importRepo( - cfg: ConfigOf< - AV, - ComAtprotoTempImportRepo.Handler>, - ComAtprotoTempImportRepo.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.importRepo' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - - pushBlob( - cfg: ConfigOf< - AV, - ComAtprotoTempPushBlob.Handler>, - ComAtprotoTempPushBlob.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.pushBlob' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - requestPhoneVerification( cfg: ConfigOf< AV, @@ -1199,17 +1174,6 @@ export class ComAtprotoTempNS { const nsid = 'com.atproto.temp.requestPhoneVerification' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } - - transferAccount( - cfg: ConfigOf< - AV, - ComAtprotoTempTransferAccount.Handler>, - ComAtprotoTempTransferAccount.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.transferAccount' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } } export class AppNS { diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index a3491462d50..6164b1706a3 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -4853,59 +4853,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempImportRepo: { - lexicon: 1, - id: 'com.atproto.temp.importRepo', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: 'application/vnd.ipld.car', - }, - output: { - encoding: 'text/plain', - }, - }, - }, - }, - ComAtprotoTempPushBlob: { - lexicon: 1, - id: 'com.atproto.temp.pushBlob', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: '*/*', - }, - }, - }, - }, ComAtprotoTempRequestPhoneVerification: { lexicon: 1, id: 'com.atproto.temp.requestPhoneVerification', @@ -4929,83 +4876,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempTransferAccount: { - lexicon: 1, - id: 'com.atproto.temp.transferAccount', - defs: { - main: { - type: 'procedure', - description: - 'Transfer an account. NOTE: temporary method, necessarily how account migration will be implemented.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did', 'plcOp'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - plcOp: { - type: 'unknown', - }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], - properties: { - accessJwt: { - type: 'string', - }, - refreshJwt: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - }, - }, - }, - errors: [ - { - name: 'InvalidHandle', - }, - { - name: 'InvalidPassword', - }, - { - name: 'InvalidInviteCode', - }, - { - name: 'HandleNotAvailable', - }, - { - name: 'UnsupportedDomain', - }, - { - name: 'UnresolvableDid', - }, - { - name: 'IncompatibleDidDoc', - }, - ], - }, - }, - }, AppBskyActorDefs: { lexicon: 1, id: 'app.bsky.actor.defs', @@ -8984,11 +8854,8 @@ export const ids = { ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempCheckSignupQueue: 'com.atproto.temp.checkSignupQueue', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', - ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', - ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', - ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', AppBskyActorGetProfile: 'app.bsky.actor.getProfile', diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/importRepo.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/importRepo.ts deleted file mode 100644 index 44bce41481c..00000000000 --- a/packages/bsky/src/lexicon/types/com/atproto/temp/importRepo.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import express from 'express' -import stream from 'stream' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { lexicons } from '../../../../lexicons' -import { isObj, hasProp } from '../../../../util' -import { CID } from 'multiformats/cid' -import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface HandlerInput { - encoding: 'application/vnd.ipld.car' - body: stream.Readable -} - -export interface HandlerSuccess { - encoding: 'text/plain' - body: Uint8Array | stream.Readable - headers?: { [key: string]: string } -} - -export interface HandlerError { - status: number - message?: string -} - -export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough -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/temp/pushBlob.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/pushBlob.ts deleted file mode 100644 index d18a60b598f..00000000000 --- a/packages/bsky/src/lexicon/types/com/atproto/temp/pushBlob.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import express from 'express' -import stream from 'stream' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { lexicons } from '../../../../lexicons' -import { isObj, hasProp } from '../../../../util' -import { CID } from 'multiformats/cid' -import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface HandlerInput { - encoding: '*/*' - body: stream.Readable -} - -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/temp/transferAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/transferAccount.ts deleted file mode 100644 index 565150caba2..00000000000 --- a/packages/bsky/src/lexicon/types/com/atproto/temp/transferAccount.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams {} - -export interface InputSchema { - handle: string - did: string - plcOp: {} - [k: string]: unknown -} - -export interface OutputSchema { - accessJwt: string - refreshJwt: string - handle: string - did: string - [k: string]: unknown -} - -export interface HandlerInput { - encoding: 'application/json' - body: InputSchema -} - -export interface HandlerSuccess { - encoding: 'application/json' - body: OutputSchema - headers?: { [key: string]: string } -} - -export interface HandlerError { - status: number - message?: string - error?: - | 'InvalidHandle' - | 'InvalidPassword' - | 'InvalidInviteCode' - | 'HandleNotAvailable' - | 'UnsupportedDomain' - | 'UnresolvableDid' - | 'IncompatibleDidDoc' -} - -export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough -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/tests/auto-moderator/labeler.test.ts b/packages/bsky/tests/auto-moderator/labeler.test.ts index 5962a3d374c..ceeee8474b2 100644 --- a/packages/bsky/tests/auto-moderator/labeler.test.ts +++ b/packages/bsky/tests/auto-moderator/labeler.test.ts @@ -39,10 +39,11 @@ describe('labeler', () => { alice = sc.dids.alice const storeBlob = (bytes: Uint8Array) => { return pdsCtx.actorStore.transact(alice, async (store) => { - const blobRef = await store.repo.blob.addUntetheredBlob( + const metadata = await store.repo.blob.uploadBlobAndGetMetadata( 'image/jpeg', Readable.from([bytes], { objectMode: false }), ) + const blobRef = await store.repo.blob.trackUntetheredBlob(metadata) const preparedBlobRef = { cid: blobRef.ref, mimeType: 'image/jpeg', diff --git a/packages/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts index c2a05b796d3..541e10d0937 100644 --- a/packages/common-web/src/did-doc.ts +++ b/packages/common-web/src/did-doc.ts @@ -44,6 +44,11 @@ export const getSigningKey = ( publicKeyMultibase: found.publicKeyMultibase, } } +export const getSigningDidKey = (doc: DidDocument): string | undefined => { + const parsed = getSigningKey(doc) + if (!parsed) return + return `did:key:${parsed.publicKeyMultibase}` +} export const getPdsEndpoint = (doc: DidDocument): string | undefined => { return getServiceEndpoint(doc, { diff --git a/packages/ozone/src/lexicon/index.ts b/packages/ozone/src/lexicon/index.ts index ed08ff55702..6b3b6de582a 100644 --- a/packages/ozone/src/lexicon/index.ts +++ b/packages/ozone/src/lexicon/index.ts @@ -90,10 +90,7 @@ import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCra import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' import * as ComAtprotoTempCheckSignupQueue from './types/com/atproto/temp/checkSignupQueue' import * as ComAtprotoTempFetchLabels from './types/com/atproto/temp/fetchLabels' -import * as ComAtprotoTempImportRepo from './types/com/atproto/temp/importRepo' -import * as ComAtprotoTempPushBlob from './types/com/atproto/temp/pushBlob' import * as ComAtprotoTempRequestPhoneVerification from './types/com/atproto/temp/requestPhoneVerification' -import * as ComAtprotoTempTransferAccount from './types/com/atproto/temp/transferAccount' import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences' import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile' import * as AppBskyActorGetProfiles from './types/app/bsky/actor/getProfiles' @@ -1167,28 +1164,6 @@ export class ComAtprotoTempNS { return this._server.xrpc.method(nsid, cfg) } - importRepo( - cfg: ConfigOf< - AV, - ComAtprotoTempImportRepo.Handler>, - ComAtprotoTempImportRepo.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.importRepo' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - - pushBlob( - cfg: ConfigOf< - AV, - ComAtprotoTempPushBlob.Handler>, - ComAtprotoTempPushBlob.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.pushBlob' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - requestPhoneVerification( cfg: ConfigOf< AV, @@ -1199,17 +1174,6 @@ export class ComAtprotoTempNS { const nsid = 'com.atproto.temp.requestPhoneVerification' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } - - transferAccount( - cfg: ConfigOf< - AV, - ComAtprotoTempTransferAccount.Handler>, - ComAtprotoTempTransferAccount.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.transferAccount' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } } export class AppNS { diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index a3491462d50..6164b1706a3 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -4853,59 +4853,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempImportRepo: { - lexicon: 1, - id: 'com.atproto.temp.importRepo', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: 'application/vnd.ipld.car', - }, - output: { - encoding: 'text/plain', - }, - }, - }, - }, - ComAtprotoTempPushBlob: { - lexicon: 1, - id: 'com.atproto.temp.pushBlob', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: '*/*', - }, - }, - }, - }, ComAtprotoTempRequestPhoneVerification: { lexicon: 1, id: 'com.atproto.temp.requestPhoneVerification', @@ -4929,83 +4876,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempTransferAccount: { - lexicon: 1, - id: 'com.atproto.temp.transferAccount', - defs: { - main: { - type: 'procedure', - description: - 'Transfer an account. NOTE: temporary method, necessarily how account migration will be implemented.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did', 'plcOp'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - plcOp: { - type: 'unknown', - }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], - properties: { - accessJwt: { - type: 'string', - }, - refreshJwt: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - }, - }, - }, - errors: [ - { - name: 'InvalidHandle', - }, - { - name: 'InvalidPassword', - }, - { - name: 'InvalidInviteCode', - }, - { - name: 'HandleNotAvailable', - }, - { - name: 'UnsupportedDomain', - }, - { - name: 'UnresolvableDid', - }, - { - name: 'IncompatibleDidDoc', - }, - ], - }, - }, - }, AppBskyActorDefs: { lexicon: 1, id: 'app.bsky.actor.defs', @@ -8984,11 +8854,8 @@ export const ids = { ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempCheckSignupQueue: 'com.atproto.temp.checkSignupQueue', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', - ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', - ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', - ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', AppBskyActorGetProfile: 'app.bsky.actor.getProfile', diff --git a/packages/ozone/src/lexicon/types/com/atproto/temp/importRepo.ts b/packages/ozone/src/lexicon/types/com/atproto/temp/importRepo.ts deleted file mode 100644 index 44bce41481c..00000000000 --- a/packages/ozone/src/lexicon/types/com/atproto/temp/importRepo.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import express from 'express' -import stream from 'stream' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { lexicons } from '../../../../lexicons' -import { isObj, hasProp } from '../../../../util' -import { CID } from 'multiformats/cid' -import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface HandlerInput { - encoding: 'application/vnd.ipld.car' - body: stream.Readable -} - -export interface HandlerSuccess { - encoding: 'text/plain' - body: Uint8Array | stream.Readable - headers?: { [key: string]: string } -} - -export interface HandlerError { - status: number - message?: string -} - -export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough -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/ozone/src/lexicon/types/com/atproto/temp/pushBlob.ts b/packages/ozone/src/lexicon/types/com/atproto/temp/pushBlob.ts deleted file mode 100644 index d18a60b598f..00000000000 --- a/packages/ozone/src/lexicon/types/com/atproto/temp/pushBlob.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import express from 'express' -import stream from 'stream' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { lexicons } from '../../../../lexicons' -import { isObj, hasProp } from '../../../../util' -import { CID } from 'multiformats/cid' -import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface HandlerInput { - encoding: '*/*' - body: stream.Readable -} - -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/ozone/src/lexicon/types/com/atproto/temp/transferAccount.ts b/packages/ozone/src/lexicon/types/com/atproto/temp/transferAccount.ts deleted file mode 100644 index 565150caba2..00000000000 --- a/packages/ozone/src/lexicon/types/com/atproto/temp/transferAccount.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams {} - -export interface InputSchema { - handle: string - did: string - plcOp: {} - [k: string]: unknown -} - -export interface OutputSchema { - accessJwt: string - refreshJwt: string - handle: string - did: string - [k: string]: unknown -} - -export interface HandlerInput { - encoding: 'application/json' - body: InputSchema -} - -export interface HandlerSuccess { - encoding: 'application/json' - body: OutputSchema - headers?: { [key: string]: string } -} - -export interface HandlerError { - status: number - message?: string - error?: - | 'InvalidHandle' - | 'InvalidPassword' - | 'InvalidInviteCode' - | 'HandleNotAvailable' - | 'UnsupportedDomain' - | 'UnresolvableDid' - | 'IncompatibleDidDoc' -} - -export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough -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/package.json b/packages/pds/package.json index 7476fc650ba..4140b575645 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.3.19", + "version": "0.4.0-beta", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ diff --git a/packages/pds/src/account-manager/db/migrations/002-account-deactivation.ts b/packages/pds/src/account-manager/db/migrations/002-account-deactivation.ts new file mode 100644 index 00000000000..fad3c21d230 --- /dev/null +++ b/packages/pds/src/account-manager/db/migrations/002-account-deactivation.ts @@ -0,0 +1,17 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('actor') + .addColumn('deactivatedAt', 'varchar') + .execute() + await db.schema + .alterTable('actor') + .addColumn('deleteAfter', 'varchar') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('actor').dropColumn('deactivatedAt').execute() + await db.schema.alterTable('actor').dropColumn('deleteAfter').execute() +} diff --git a/packages/pds/src/account-manager/db/migrations/index.ts b/packages/pds/src/account-manager/db/migrations/index.ts index 4b694f0f0f4..154dcd3ea9a 100644 --- a/packages/pds/src/account-manager/db/migrations/index.ts +++ b/packages/pds/src/account-manager/db/migrations/index.ts @@ -1,5 +1,7 @@ -import * as init from './001-init' +import * as mig001 from './001-init' +import * as mig002 from './002-account-deactivation' export default { - '001': init, + '001': mig001, + '002': mig002, } diff --git a/packages/pds/src/account-manager/db/schema/actor.ts b/packages/pds/src/account-manager/db/schema/actor.ts index cfb3fcbe66b..999add6b966 100644 --- a/packages/pds/src/account-manager/db/schema/actor.ts +++ b/packages/pds/src/account-manager/db/schema/actor.ts @@ -5,6 +5,8 @@ export interface Actor { handle: string | null createdAt: string takedownRef: string | null + deactivatedAt: string | null + deleteAfter: string | null } export type ActorEntry = Selectable diff --git a/packages/pds/src/account-manager/db/schema/email-token.ts b/packages/pds/src/account-manager/db/schema/email-token.ts index c544a95ce54..c69a57a0a29 100644 --- a/packages/pds/src/account-manager/db/schema/email-token.ts +++ b/packages/pds/src/account-manager/db/schema/email-token.ts @@ -3,6 +3,7 @@ export type EmailTokenPurpose = | 'update_email' | 'reset_password' | 'delete_account' + | 'plc_operation' export interface EmailToken { purpose: EmailTokenPurpose diff --git a/packages/pds/src/account-manager/helpers/account.ts b/packages/pds/src/account-manager/helpers/account.ts index f0e87c6d0ed..344c06a3778 100644 --- a/packages/pds/src/account-manager/helpers/account.ts +++ b/packages/pds/src/account-manager/helpers/account.ts @@ -1,6 +1,7 @@ import { isErrUniqueViolation, notSoftDeletedClause } from '../../db' import { AccountDb, ActorEntry } from '../db' import { StatusAttr } from '../../lexicon/types/com/atproto/admin/defs' +import { DAY } from '@atproto/common' export class UserAlreadyExistsError extends Error {} @@ -10,19 +11,28 @@ export type ActorAccount = ActorEntry & { invitesDisabled: 0 | 1 | null } -const selectAccountQB = (db: AccountDb, includeSoftDeleted: boolean) => { +export type AvailabilityFlags = { + includeTakenDown?: boolean + includeDeactivated?: boolean +} + +const selectAccountQB = (db: AccountDb, flags?: AvailabilityFlags) => { + const { includeTakenDown = false, includeDeactivated = false } = flags ?? {} const { ref } = db.db.dynamic return db.db .selectFrom('actor') .leftJoin('account', 'actor.did', 'account.did') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('actor'))), + .if(!includeTakenDown, (qb) => qb.where(notSoftDeletedClause(ref('actor')))) + .if(!includeDeactivated, (qb) => + qb.where('actor.deactivatedAt', 'is', null), ) .select([ 'actor.did', 'actor.handle', 'actor.createdAt', 'actor.takedownRef', + 'actor.deactivatedAt', + 'actor.deleteAfter', 'account.email', 'account.emailConfirmedAt', 'account.invitesDisabled', @@ -32,9 +42,9 @@ const selectAccountQB = (db: AccountDb, includeSoftDeleted: boolean) => { export const getAccount = async ( db: AccountDb, handleOrDid: string, - includeSoftDeleted = false, + flags?: AvailabilityFlags, ): Promise => { - const found = await selectAccountQB(db, includeSoftDeleted) + const found = await selectAccountQB(db, flags) .where((qb) => { if (handleOrDid.startsWith('did:')) { return qb.where('actor.did', '=', handleOrDid) @@ -49,9 +59,9 @@ export const getAccount = async ( export const getAccountByEmail = async ( db: AccountDb, email: string, - includeSoftDeleted = false, + flags?: AvailabilityFlags, ): Promise => { - const found = await selectAccountQB(db, includeSoftDeleted) + const found = await selectAccountQB(db, flags) .where('email', '=', email.toLowerCase()) .executeTakeFirst() return found || null @@ -62,16 +72,21 @@ export const registerActor = async ( opts: { did: string handle: string + deactivated?: boolean }, ) => { - const { did, handle } = opts + const { did, handle, deactivated } = opts + const now = Date.now() + const createdAt = new Date(now).toISOString() const [registered] = await db.executeWithRetry( db.db .insertInto('actor') .values({ did, handle, - createdAt: new Date().toISOString(), + createdAt, + deactivatedAt: deactivated ? createdAt : null, + deleteAfter: deactivated ? new Date(now + 3 * DAY).toISOString() : null, }) .onConflict((oc) => oc.doNothing()) .returning('did'), @@ -208,3 +223,31 @@ export const updateAccountTakedownStatus = async ( db.db.updateTable('actor').set({ takedownRef }).where('did', '=', did), ) } + +export const deactivateAccount = async ( + db: AccountDb, + did: string, + deleteAfter: string | null, +) => { + await db.executeWithRetry( + db.db + .updateTable('actor') + .set({ + deactivatedAt: new Date().toISOString(), + deleteAfter, + }) + .where('did', '=', did), + ) +} + +export const activateAccount = async (db: AccountDb, did: string) => { + await db.executeWithRetry( + db.db + .updateTable('actor') + .set({ + deactivatedAt: null, + deleteAfter: null, + }) + .where('did', '=', did), + ) +} diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 469d942aea9..d071550ee69 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -39,16 +39,16 @@ export class AccountManager { async getAccount( handleOrDid: string, - includeSoftDeleted = false, + flags?: account.AvailabilityFlags, ): Promise { - return account.getAccount(this.db, handleOrDid, includeSoftDeleted) + return account.getAccount(this.db, handleOrDid, flags) } async getAccountByEmail( email: string, - includeSoftDeleted = false, + flags?: account.AvailabilityFlags, ): Promise { - return account.getAccountByEmail(this.db, email, includeSoftDeleted) + return account.getAccountByEmail(this.db, email, flags) } // Repo exists and is not taken-down @@ -57,11 +57,17 @@ export class AccountManager { return !!got } + async isAccountActivated(did: string): Promise { + const account = await this.getAccount(did, { includeDeactivated: true }) + if (!account) return false + return !account.deactivatedAt + } + async getDidForActor( handleOrDid: string, - includeSoftDeleted = false, + flags?: account.AvailabilityFlags, ): Promise { - const got = await this.getAccount(handleOrDid, includeSoftDeleted) + const got = await this.getAccount(handleOrDid, flags) return got?.did ?? null } @@ -73,8 +79,18 @@ export class AccountManager { repoCid: CID repoRev: string inviteCode?: string + deactivated?: boolean }) { - const { did, handle, email, password, repoCid, repoRev, inviteCode } = opts + const { + did, + handle, + email, + password, + repoCid, + repoRev, + inviteCode, + deactivated, + } = opts const passwordScrypt = password ? await scrypt.genSaltAndHash(password) : undefined @@ -92,7 +108,7 @@ export class AccountManager { await invite.ensureInviteIsAvailable(dbTxn, inviteCode) } await Promise.all([ - account.registerActor(dbTxn, { did, handle }), + account.registerActor(dbTxn, { did, handle, deactivated }), email && passwordScrypt ? account.registerAccount(dbTxn, { did, email, passwordScrypt }) : Promise.resolve(), @@ -135,6 +151,14 @@ export class AccountManager { return repo.updateRoot(this.db, did, cid, rev) } + async deactivateAccount(did: string, deleteAfter: string | null) { + return account.deactivateAccount(this.db, did, deleteAfter) + } + + async activateAccount(did: string) { + return account.activateAccount(this.db, did) + } + // Auth // ---------- @@ -309,6 +333,15 @@ export class AccountManager { return emailToken.assertValidToken(this.db, did, purpose, token) } + async assertValidEmailTokenAndCleanup( + did: string, + purpose: EmailTokenPurpose, + token: string, + ) { + await emailToken.assertValidToken(this.db, did, purpose, token) + await emailToken.deleteEmailToken(this.db, did, purpose) + } + async confirmEmail(opts: { did: string; token: string }) { const { did, token } = opts await emailToken.assertValidToken(this.db, did, 'confirm_email', token) diff --git a/packages/pds/src/actor-store/blob/reader.ts b/packages/pds/src/actor-store/blob/reader.ts index bb38ed92e69..299358b1119 100644 --- a/packages/pds/src/actor-store/blob/reader.ts +++ b/packages/pds/src/actor-store/blob/reader.ts @@ -3,7 +3,7 @@ import { CID } from 'multiformats/cid' import { BlobNotFoundError, BlobStore } from '@atproto/repo' import { InvalidRequestError } from '@atproto/xrpc-server' import { ActorDb } from '../db' -import { notSoftDeletedClause } from '../../db/util' +import { countAll, countDistinct, notSoftDeletedClause } from '../../db/util' import { StatusAttr } from '../../lexicon/types/com/atproto/admin/defs' export class BlobReader { @@ -73,4 +73,57 @@ export class BlobReader { ? { applied: true, ref: res.takedownRef } : { applied: false } } + + async getRecordsForBlob(cid: CID): Promise { + const res = await this.db.db + .selectFrom('record_blob') + .where('blobCid', '=', cid.toString()) + .selectAll() + .execute() + return res.map((row) => row.recordUri) + } + + async blobCount(): Promise { + const res = await this.db.db + .selectFrom('blob') + .select(countAll.as('count')) + .executeTakeFirst() + return res?.count ?? 0 + } + + async recordBlobCount(): Promise { + const { ref } = this.db.db.dynamic + const res = await this.db.db + .selectFrom('record_blob') + .select(countDistinct(ref('blobCid')).as('count')) + .executeTakeFirst() + return res?.count ?? 0 + } + + async listMissingBlobs(opts: { + cursor?: string + limit: number + }): Promise<{ cid: string; recordUri: string }[]> { + const { cursor, limit } = opts + let builder = this.db.db + .selectFrom('record_blob') + .whereNotExists((qb) => + qb + .selectFrom('blob') + .selectAll() + .whereRef('blob.cid', '=', 'record_blob.blobCid'), + ) + .selectAll() + .orderBy('blobCid', 'asc') + .groupBy('blobCid') + .limit(limit) + if (cursor) { + builder = builder.where('blobCid', '>', cursor) + } + const res = await builder.execute() + return res.map((row) => ({ + cid: row.blobCid, + recordUri: row.recordUri, + })) + } } diff --git a/packages/pds/src/actor-store/blob/transactor.ts b/packages/pds/src/actor-store/blob/transactor.ts index 013235639a0..fa937e6bec6 100644 --- a/packages/pds/src/actor-store/blob/transactor.ts +++ b/packages/pds/src/actor-store/blob/transactor.ts @@ -20,6 +20,15 @@ import { BackgroundQueue } from '../../background' import { BlobReader } from './reader' import { StatusAttr } from '../../lexicon/types/com/atproto/admin/defs' +export type BlobMetadata = { + tempKey: string + size: number + cid: CID + mimeType: string + width: number | null + height: number | null +} + export class BlobTransactor extends BlobReader { constructor( public db: ActorDb, @@ -29,10 +38,10 @@ export class BlobTransactor extends BlobReader { super(db, blobstore) } - async addUntetheredBlob( + async uploadBlobAndGetMetadata( userSuggestedMime: string, blobStream: stream.Readable, - ): Promise { + ): Promise { const [tempKey, size, sha256, imgInfo, sniffedMime] = await Promise.all([ this.blobstore.putTemp(cloneStream(blobStream)), streamSize(cloneStream(blobStream)), @@ -44,6 +53,27 @@ export class BlobTransactor extends BlobReader { const cid = sha256RawToCid(sha256) const mimeType = sniffedMime || userSuggestedMime + return { + tempKey, + size, + cid, + mimeType, + width: imgInfo?.width ?? null, + height: imgInfo?.height ?? null, + } + } + + async trackUntetheredBlob(metadata: BlobMetadata) { + const { tempKey, size, cid, mimeType, width, height } = metadata + const found = await this.db.db + .selectFrom('blob') + .selectAll() + .where('cid', '=', cid.toString()) + .executeTakeFirst() + if (found?.takedownRef) { + throw new InvalidRequestError('Blob has been takendown, cannot re-upload') + } + await this.db.db .insertInto('blob') .values({ @@ -51,8 +81,8 @@ export class BlobTransactor extends BlobReader { mimeType, size, tempKey, - width: imgInfo?.width || null, - height: imgInfo?.height || null, + width, + height, createdAt: new Date().toISOString(), }) .onConflict((oc) => diff --git a/packages/pds/src/actor-store/index.ts b/packages/pds/src/actor-store/index.ts index 1ac7ea52bb3..698956b65f5 100644 --- a/packages/pds/src/actor-store/index.ts +++ b/packages/pds/src/actor-store/index.ts @@ -107,6 +107,30 @@ export class ActorStore { } } + async writeNoTransaction(did: string, fn: ActorStoreWriterFn) { + const keypair = await this.keypair(did) + const db = await this.openDb(did) + try { + const writer = createActorTransactor(did, db, keypair, this.resources) + return await fn({ + ...writer, + transact: async (fn: ActorStoreTransactFn): Promise => { + return db.transaction((dbTxn) => { + const transactor = createActorTransactor( + did, + dbTxn, + keypair, + this.resources, + ) + return fn(transactor) + }) + }, + }) + } finally { + db.close() + } + } + async create(did: string, keypair: ExportableKeypair) { const { directory, dbLocation, keyLocation } = await this.getLocation(did) // ensure subdir exists @@ -244,6 +268,7 @@ const createActorReader = ( export type ActorStoreReadFn = (fn: ActorStoreReader) => Promise export type ActorStoreTransactFn = (fn: ActorStoreTransactor) => Promise +export type ActorStoreWriterFn = (fn: ActorStoreWriter) => Promise export type ActorStoreReader = { did: string @@ -262,6 +287,10 @@ export type ActorStoreTransactor = { pref: PreferenceTransactor } +export type ActorStoreWriter = ActorStoreTransactor & { + transact: (fn: ActorStoreTransactFn) => Promise +} + function assertSafePathPart(part: string) { const normalized = path.normalize(part) assert( diff --git a/packages/pds/src/actor-store/record/reader.ts b/packages/pds/src/actor-store/record/reader.ts index ed8d231f3cf..7c7b3383439 100644 --- a/packages/pds/src/actor-store/record/reader.ts +++ b/packages/pds/src/actor-store/record/reader.ts @@ -2,7 +2,7 @@ import * as syntax from '@atproto/syntax' import { AtUri, ensureValidAtUri } from '@atproto/syntax' import { cborToLexRecord } from '@atproto/repo' import { CID } from 'multiformats/cid' -import { notSoftDeletedClause } from '../../db/util' +import { countAll, notSoftDeletedClause } from '../../db/util' import { ids } from '../../lexicon/lexicons' import { ActorDb, Backlink } from '../db' import { StatusAttr } from '../../lexicon/types/com/atproto/admin/defs' @@ -11,6 +11,14 @@ import { RepoRecord } from '@atproto/lexicon' export class RecordReader { constructor(public db: ActorDb) {} + async recordCount(): Promise { + const res = await this.db.db + .selectFrom('record') + .select(countAll.as('count')) + .executeTakeFirst() + return res?.count ?? 0 + } + async listCollections(): Promise { const collections = await this.db.db .selectFrom('record') diff --git a/packages/pds/src/actor-store/repo/sql-repo-reader.ts b/packages/pds/src/actor-store/repo/sql-repo-reader.ts index 90b61b08e68..30f994031c4 100644 --- a/packages/pds/src/actor-store/repo/sql-repo-reader.ts +++ b/packages/pds/src/actor-store/repo/sql-repo-reader.ts @@ -8,6 +8,7 @@ import { chunkArray } from '@atproto/common' import { CID } from 'multiformats/cid' import { ActorDb } from '../db' import { sql } from 'kysely' +import { countAll } from '../../db' export class SqlRepoReader extends ReadableBlockstore { cache: BlockMap = new BlockMap() @@ -136,6 +137,14 @@ export class SqlRepoReader extends ReadableBlockstore { return builder.execute() } + async countBlocks(): Promise { + const res = await this.db.db + .selectFrom('repo_block') + .select(countAll.as('count')) + .executeTakeFirst() + return res?.count ?? 0 + } + async destroy(): Promise { throw new Error('Destruction of SQL repo storage not allowed at runtime') } diff --git a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts index 7b94a8c57d8..e258f9714b2 100644 --- a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts +++ b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts @@ -8,7 +8,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.roleOrAdminService, handler: async ({ params }) => { const [account, invites, invitedBy] = await Promise.all([ - ctx.accountManager.getAccount(params.did, true), + ctx.accountManager.getAccount(params.did, { + includeDeactivated: true, + includeTakenDown: true, + }), ctx.accountManager.getAccountInvitesCodes(params.did), ctx.accountManager.getInvitedByForAccounts([params.did]), ]) diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index 169eaf4e6e2..b61e9e0d158 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -18,7 +18,10 @@ export default function (server: Server, ctx: AppContext) { subject = 'Message from Bluesky moderator', comment, } = input.body - const account = await ctx.accountManager.getAccount(recipientDid) + const account = await ctx.accountManager.getAccount(recipientDid, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!account) { throw new InvalidRequestError('Recipient not found') } diff --git a/packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts b/packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts index 6e42905b83d..c2f266d716d 100644 --- a/packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts @@ -10,7 +10,10 @@ export default function (server: Server, ctx: AppContext) { if (!auth.credentials.admin) { throw new AuthRequiredError('Insufficient privileges') } - const account = await ctx.accountManager.getAccount(input.body.account) + const account = await ctx.accountManager.getAccount(input.body.account, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!account) { throw new InvalidRequestError( `Account does not exist: ${input.body.account}`, diff --git a/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts b/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts index b13d1ee54aa..627f1aaebc9 100644 --- a/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts +++ b/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts @@ -21,7 +21,10 @@ export default function (server: Server, ctx: AppContext) { }) // Pessimistic check to handle spam: also enforced by updateHandle() and the db. - const account = await ctx.accountManager.getAccount(handle) + const account = await ctx.accountManager.getAccount(handle, { + includeDeactivated: true, + includeTakenDown: true, + }) if (account) { if (account.did !== did) { diff --git a/packages/pds/src/api/com/atproto/identity/getRecommendedDidCredentials.ts b/packages/pds/src/api/com/atproto/identity/getRecommendedDidCredentials.ts new file mode 100644 index 00000000000..cd163f16566 --- /dev/null +++ b/packages/pds/src/api/com/atproto/identity/getRecommendedDidCredentials.ts @@ -0,0 +1,45 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.identity.getRecommendedDidCredentials({ + auth: ctx.authVerifier.access, + handler: async ({ auth }) => { + const requester = auth.credentials.did + const signingKey = await ctx.actorStore.keypair(requester) + const verificationMethods = { + atproto: signingKey.did(), + } + const account = await ctx.accountManager.getAccount(requester, { + includeDeactivated: true, + }) + const alsoKnownAs = account?.handle + ? [`at://${account.handle}`] + : undefined + + const plcRotationKey = + ctx.cfg.entryway?.plcRotationKey ?? ctx.plcRotationKey.did() + const rotationKeys = [plcRotationKey] + if (ctx.cfg.identity.recoveryDidKey) { + rotationKeys.unshift(ctx.cfg.identity.recoveryDidKey) + } + + const services = { + atproto_pds: { + type: 'AtprotoPersonalDataServer', + endpoint: ctx.cfg.service.publicUrl, + }, + } + + return { + encoding: 'application/json', + body: { + alsoKnownAs, + verificationMethods, + rotationKeys, + services, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/identity/index.ts b/packages/pds/src/api/com/atproto/identity/index.ts index 724b82de197..0a1d72a919b 100644 --- a/packages/pds/src/api/com/atproto/identity/index.ts +++ b/packages/pds/src/api/com/atproto/identity/index.ts @@ -2,8 +2,16 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import resolveHandle from './resolveHandle' import updateHandle from './updateHandle' +import getRecommendedDidCredentials from './getRecommendedDidCredentials' +import requestPlcOperationSignature from './requestPlcOperationSignature' +import signPlcOperation from './signPlcOperation' +import submitPlcOperation from './submitPlcOperation' export default function (server: Server, ctx: AppContext) { resolveHandle(server, ctx) updateHandle(server, ctx) + getRecommendedDidCredentials(server, ctx) + requestPlcOperationSignature(server, ctx) + signPlcOperation(server, ctx) + submitPlcOperation(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/identity/requestPlcOperationSignature.ts b/packages/pds/src/api/com/atproto/identity/requestPlcOperationSignature.ts new file mode 100644 index 00000000000..2c49a4b983b --- /dev/null +++ b/packages/pds/src/api/com/atproto/identity/requestPlcOperationSignature.ts @@ -0,0 +1,35 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { authPassthru } from '../../../proxy' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.identity.requestPlcOperationSignature({ + auth: ctx.authVerifier.accessNotAppPassword, + handler: async ({ auth, req }) => { + if (ctx.entrywayAgent) { + await ctx.entrywayAgent.com.atproto.identity.requestPlcOperationSignature( + undefined, + authPassthru(req), + ) + return + } + + const did = auth.credentials.did + const account = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + includeTakenDown: true, + }) + if (!account) { + throw new InvalidRequestError('account not found') + } else if (!account.email) { + throw new InvalidRequestError('account does not have an email address') + } + const token = await ctx.accountManager.createEmailToken( + did, + 'plc_operation', + ) + await ctx.mailer.sendPlcOperation({ token }, { to: account.email }) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/identity/resolveHandle.ts b/packages/pds/src/api/com/atproto/identity/resolveHandle.ts index 472d7ccb234..67e0af41520 100644 --- a/packages/pds/src/api/com/atproto/identity/resolveHandle.ts +++ b/packages/pds/src/api/com/atproto/identity/resolveHandle.ts @@ -18,7 +18,7 @@ export default function (server: Server, ctx: AppContext) { } let did: string | undefined - const user = await ctx.accountManager.getAccount(handle, true) + const user = await ctx.accountManager.getAccount(handle) if (user) { did = user.did diff --git a/packages/pds/src/api/com/atproto/identity/signPlcOperation.ts b/packages/pds/src/api/com/atproto/identity/signPlcOperation.ts new file mode 100644 index 00000000000..95381aa8588 --- /dev/null +++ b/packages/pds/src/api/com/atproto/identity/signPlcOperation.ts @@ -0,0 +1,58 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import * as plc from '@did-plc/lib' +import { check } from '@atproto/common' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { authPassthru, resultPassthru } from '../../../proxy' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.identity.signPlcOperation({ + auth: ctx.authVerifier.accessNotAppPassword, + handler: async ({ auth, input, req }) => { + if (ctx.entrywayAgent) { + return resultPassthru( + await ctx.entrywayAgent.com.atproto.identity.signPlcOperation( + input.body, + authPassthru(req, true), + ), + ) + } + + const did = auth.credentials.did + const { token } = input.body + if (!token) { + throw new InvalidRequestError( + 'email confirmation token required to sign PLC operations', + ) + } + await ctx.accountManager.assertValidEmailTokenAndCleanup( + did, + 'plc_operation', + token, + ) + + const lastOp = await ctx.plcClient.getLastOp(did) + if (check.is(lastOp, plc.def.tombstone)) { + throw new InvalidRequestError('Did is tombstoned') + } + const operation = await plc.createUpdateOp( + lastOp, + ctx.plcRotationKey, + (lastOp) => ({ + ...lastOp, + rotationKeys: input.body.rotationKeys ?? lastOp.rotationKeys, + alsoKnownAs: input.body.alsoKnownAs ?? lastOp.alsoKnownAs, + verificationMethods: + input.body.verificationMethods ?? lastOp.verificationMethods, + services: input.body.services ?? lastOp.services, + }), + ) + return { + encoding: 'application/json', + body: { + operation, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/identity/submitPlcOperation.ts b/packages/pds/src/api/com/atproto/identity/submitPlcOperation.ts new file mode 100644 index 00000000000..66db47fe482 --- /dev/null +++ b/packages/pds/src/api/com/atproto/identity/submitPlcOperation.ts @@ -0,0 +1,48 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import * as plc from '@did-plc/lib' +import { check } from '@atproto/common' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.identity.submitPlcOperation({ + auth: ctx.authVerifier.access, + handler: async ({ auth, input }) => { + const requester = auth.credentials.did + const op = input.body.operation + + if (!check.is(op, plc.def.operation)) { + throw new InvalidRequestError('Invalid operation') + } + + if (!op.rotationKeys.includes(ctx.plcRotationKey.did())) { + throw new InvalidRequestError( + "Rotation keys do not include server's rotation key", + ) + } + if (op.services['atproto_pds']?.type !== 'AtprotoPersonalDataServer') { + throw new InvalidRequestError('Incorrect type on atproto_pds service') + } + if (op.services['atproto_pds']?.endpoint !== ctx.cfg.service.publicUrl) { + throw new InvalidRequestError( + 'Incorrect endpoint on atproto_pds service', + ) + } + const signingKey = await ctx.actorStore.keypair(requester) + if (op.verificationMethods['atproto'] !== signingKey.did()) { + throw new InvalidRequestError('Incorrect signing key') + } + const account = await ctx.accountManager.getAccount(requester, { + includeDeactivated: true, + }) + if ( + account?.handle && + op.alsoKnownAs.at(0) !== `at://${account.handle}` + ) { + throw new InvalidRequestError('Incorrect handle in alsoKnownAs') + } + + await ctx.plcClient.sendOperation(requester, op) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/identity/updateHandle.ts b/packages/pds/src/api/com/atproto/identity/updateHandle.ts index 32018601756..eb1bce9593c 100644 --- a/packages/pds/src/api/com/atproto/identity/updateHandle.ts +++ b/packages/pds/src/api/com/atproto/identity/updateHandle.ts @@ -42,7 +42,9 @@ export default function (server: Server, ctx: AppContext) { }) // Pessimistic check to handle spam: also enforced by updateHandle() and the db. - const account = await ctx.accountManager.getAccount(handle) + const account = await ctx.accountManager.getAccount(handle, { + includeDeactivated: true, + }) if (account) { if (account.did !== requester) { diff --git a/packages/pds/src/api/com/atproto/repo/applyWrites.ts b/packages/pds/src/api/com/atproto/repo/applyWrites.ts index 1fd1bbb531e..16f620a30fc 100644 --- a/packages/pds/src/api/com/atproto/repo/applyWrites.ts +++ b/packages/pds/src/api/com/atproto/repo/applyWrites.ts @@ -48,11 +48,17 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input, auth }) => { const tx = input.body const { repo, validate, swapCommit } = tx - const did = await ctx.accountManager.getDidForActor(repo) + const account = await ctx.accountManager.getAccount(repo, { + includeDeactivated: true, + }) - if (!did) { + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) + } else if (account.deactivatedAt) { + throw new InvalidRequestError('Account is deactivated') } + + const did = account.did if (did !== auth.credentials.did) { throw new AuthRequiredError() } diff --git a/packages/pds/src/api/com/atproto/repo/createRecord.ts b/packages/pds/src/api/com/atproto/repo/createRecord.ts index 8d7aaedd11c..7e3aead0c56 100644 --- a/packages/pds/src/api/com/atproto/repo/createRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/createRecord.ts @@ -28,11 +28,16 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input, auth }) => { const { repo, collection, rkey, record, swapCommit, validate } = input.body - const did = await ctx.accountManager.getDidForActor(repo) + const account = await ctx.accountManager.getAccount(repo, { + includeDeactivated: true, + }) - if (!did) { + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) + } else if (account.deactivatedAt) { + throw new InvalidRequestError('Account is deactivated') } + const did = account.did if (did !== auth.credentials.did) { throw new AuthRequiredError() } diff --git a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts index 5a98c0d9963..51589e9d2bc 100644 --- a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts @@ -22,11 +22,16 @@ export default function (server: Server, ctx: AppContext) { ], handler: async ({ input, auth }) => { const { repo, collection, rkey, swapCommit, swapRecord } = input.body - const did = await ctx.accountManager.getDidForActor(repo) + const account = await ctx.accountManager.getAccount(repo, { + includeDeactivated: true, + }) - if (!did) { + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) + } else if (account.deactivatedAt) { + throw new InvalidRequestError('Account is deactivated') } + const did = account.did if (did !== auth.credentials.did) { throw new AuthRequiredError() } diff --git a/packages/pds/src/api/com/atproto/repo/importRepo.ts b/packages/pds/src/api/com/atproto/repo/importRepo.ts new file mode 100644 index 00000000000..1e320c99088 --- /dev/null +++ b/packages/pds/src/api/com/atproto/repo/importRepo.ts @@ -0,0 +1,135 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { ActorStoreTransactor } from '../../../../actor-store' +import { TID } from '@atproto/common' +import { + Repo, + WriteOpAction, + getAndParseRecord, + readCarStream, + verifyDiff, +} from '@atproto/repo' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { CID } from 'multiformats/cid' +import PQueue from 'p-queue' +import { AtUri } from '@atproto/syntax' +import { BlobRef, LexValue, RepoRecord } from '@atproto/lexicon' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.repo.importRepo({ + auth: ctx.authVerifier.accessNotAppPassword, + handler: async ({ input, auth }) => { + const did = auth.credentials.did + if (!ctx.cfg.service.acceptingImports) { + throw new InvalidRequestError('Service is not accepting repo imports') + } + await ctx.actorStore.transact(did, (store) => + importRepo(store, input.body), + ) + }, + }) +} + +const importRepo = async ( + actorStore: ActorStoreTransactor, + incomingCar: AsyncIterable, +) => { + const now = new Date().toISOString() + const rev = TID.nextStr() + const did = actorStore.repo.did + + const { roots, blocks } = await readCarStream(incomingCar) + if (roots.length !== 1) { + throw new InvalidRequestError('expected one root') + } + const currRoot = await actorStore.db.db + .selectFrom('repo_root') + .selectAll() + .executeTakeFirst() + const currRepo = currRoot + ? await Repo.load(actorStore.repo.storage, CID.parse(currRoot.cid)) + : null + const diff = await verifyDiff( + currRepo, + blocks, + roots[0], + undefined, + undefined, + { ensureLeaves: false }, + ) + diff.commit.rev = rev + await actorStore.repo.storage.applyCommit(diff.commit, currRepo === null) + const recordQueue = new PQueue({ concurrency: 50 }) + const controller = new AbortController() + for (const write of diff.writes) { + recordQueue + .add( + async () => { + const uri = AtUri.make(did, write.collection, write.rkey) + if (write.action === WriteOpAction.Delete) { + await actorStore.record.deleteRecord(uri) + } else { + let parsedRecord: RepoRecord + try { + const parsed = await getAndParseRecord(blocks, write.cid) + parsedRecord = parsed.record + } catch { + throw new InvalidRequestError( + `Could not parse record at '${write.collection}/${write.rkey}'`, + ) + } + const indexRecord = actorStore.record.indexRecord( + uri, + write.cid, + parsedRecord, + write.action, + rev, + now, + ) + const recordBlobs = findBlobRefs(parsedRecord) + const blobValues = recordBlobs.map((cid) => ({ + recordUri: uri.toString(), + blobCid: cid.ref.toString(), + })) + const indexRecordBlobs = + blobValues.length > 0 + ? actorStore.db.db + .insertInto('record_blob') + .values(blobValues) + .onConflict((oc) => oc.doNothing()) + .execute() + : Promise.resolve() + await Promise.all([indexRecord, indexRecordBlobs]) + } + }, + { signal: controller.signal }, + ) + .catch((err) => controller.abort(err)) + } + await recordQueue.onIdle() + controller.signal.throwIfAborted() +} + +export const findBlobRefs = (val: LexValue, layer = 0): BlobRef[] => { + if (layer > 32) { + return [] + } + // walk arrays + if (Array.isArray(val)) { + return val.flatMap((item) => findBlobRefs(item, layer + 1)) + } + // objects + if (val && typeof val === 'object') { + // convert blobs, leaving the original encoding so that we don't change CIDs on re-encode + if (val instanceof BlobRef) { + return [val] + } + // retain cids & bytes + if (CID.asCID(val) || val instanceof Uint8Array) { + return [] + } + return Object.values(val).flatMap((item) => findBlobRefs(item, layer + 1)) + } + // pass through + return [] +} diff --git a/packages/pds/src/api/com/atproto/repo/index.ts b/packages/pds/src/api/com/atproto/repo/index.ts index ce13a10fe15..5c754064e95 100644 --- a/packages/pds/src/api/com/atproto/repo/index.ts +++ b/packages/pds/src/api/com/atproto/repo/index.ts @@ -8,6 +8,8 @@ import getRecord from './getRecord' import listRecords from './listRecords' import putRecord from './putRecord' import uploadBlob from './uploadBlob' +import listMissingBlobs from './listMissingBlobs' +import importRepo from './importRepo' export default function (server: Server, ctx: AppContext) { applyWrites(server, ctx) @@ -18,4 +20,6 @@ export default function (server: Server, ctx: AppContext) { listRecords(server, ctx) putRecord(server, ctx) uploadBlob(server, ctx) + listMissingBlobs(server, ctx) + importRepo(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/repo/listMissingBlobs.ts b/packages/pds/src/api/com/atproto/repo/listMissingBlobs.ts new file mode 100644 index 00000000000..8bb01cd5585 --- /dev/null +++ b/packages/pds/src/api/com/atproto/repo/listMissingBlobs.ts @@ -0,0 +1,23 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.repo.listMissingBlobs({ + auth: ctx.authVerifier.access, + handler: async ({ auth, params }) => { + const did = auth.credentials.did + const { limit, cursor } = params + const blobs = await ctx.actorStore.read(did, (store) => + store.repo.blob.listMissingBlobs({ limit, cursor }), + ) + + return { + encoding: 'application/json', + body: { + blobs, + cursor: blobs.at(-1)?.cid, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/repo/putRecord.ts b/packages/pds/src/api/com/atproto/repo/putRecord.ts index a0cc047e4ee..97ee3e4819f 100644 --- a/packages/pds/src/api/com/atproto/repo/putRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/putRecord.ts @@ -38,11 +38,16 @@ export default function (server: Server, ctx: AppContext) { swapCommit, swapRecord, } = input.body - const did = await ctx.accountManager.getDidForActor(repo) + const account = await ctx.accountManager.getAccount(repo, { + includeDeactivated: true, + }) - if (!did) { + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) + } else if (account.deactivatedAt) { + throw new InvalidRequestError('Account is deactivated') } + const did = account.did if (did !== auth.credentials.did) { throw new AuthRequiredError() } diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index 4bb536f804e..ca610f79d9d 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -12,9 +12,35 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ auth, input }) => { const requester = auth.credentials.did - const blob = await ctx.actorStore.transact(requester, (actorTxn) => { - return actorTxn.repo.blob.addUntetheredBlob(input.encoding, input.body) - }) + const blob = await ctx.actorStore.writeNoTransaction( + requester, + async (store) => { + const metadata = await store.repo.blob.uploadBlobAndGetMetadata( + input.encoding, + input.body, + ) + + return store.transact(async (actorTxn) => { + const blobRef = await actorTxn.repo.blob.trackUntetheredBlob( + metadata, + ) + + // make the blob permanent if an associated record is already indexed + const recordsForBlob = await actorTxn.repo.blob.getRecordsForBlob( + blobRef.ref, + ) + if (recordsForBlob.length > 0) { + await actorTxn.repo.blob.verifyBlobAndMakePermanent({ + cid: blobRef.ref, + mimeType: blobRef.mimeType, + constraints: {}, + }) + } + + return blobRef + }) + }, + ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/com/atproto/server/activateAccount.ts b/packages/pds/src/api/com/atproto/server/activateAccount.ts new file mode 100644 index 00000000000..90170084801 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/activateAccount.ts @@ -0,0 +1,32 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { assertValidDidDocumentForService } from './util' +import { CidSet } from '@atproto/repo' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.activateAccount({ + auth: ctx.authVerifier.accessNotAppPassword, + handler: async ({ auth }) => { + const requester = auth.credentials.did + + await assertValidDidDocumentForService(ctx, requester) + + await ctx.accountManager.activateAccount(requester) + + const commitData = await ctx.actorStore.read(requester, async (store) => { + const root = await store.repo.storage.getRootDetailed() + const blocks = await store.repo.storage.getBlocks([root.cid]) + return { + cid: root.cid, + rev: root.rev, + since: null, + prev: null, + newBlocks: blocks.blocks, + removedCids: new CidSet(), + } + }) + + await ctx.sequencer.sequenceCommit(requester, commitData, []) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/checkAccountStatus.ts b/packages/pds/src/api/com/atproto/server/checkAccountStatus.ts new file mode 100644 index 00000000000..9ceb7dbcbe9 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/checkAccountStatus.ts @@ -0,0 +1,46 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { isValidDidDocForService } from './util' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.checkAccountStatus({ + auth: ctx.authVerifier.access, + handler: async ({ auth }) => { + const requester = auth.credentials.did + const [ + repoRoot, + repoBlocks, + indexedRecords, + importedBlobs, + expectedBlobs, + ] = await ctx.actorStore.read(requester, async (store) => { + return await Promise.all([ + store.repo.storage.getRootDetailed(), + store.repo.storage.countBlocks(), + store.record.recordCount(), + store.repo.blob.blobCount(), + store.repo.blob.recordBlobCount(), + ]) + }) + const [activated, validDid] = await Promise.all([ + ctx.accountManager.isAccountActivated(requester), + isValidDidDocForService(ctx, requester), + ]) + + return { + encoding: 'application/json', + body: { + activated, + validDid, + repoCommit: repoRoot.cid.toString(), + repoRev: repoRoot.rev, + repoBlocks, + indexedRecords, + privateStateValues: 0, + expectedBlobs, + importedBlobs, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/confirmEmail.ts b/packages/pds/src/api/com/atproto/server/confirmEmail.ts index cbe7e5a0f74..b48165b4f70 100644 --- a/packages/pds/src/api/com/atproto/server/confirmEmail.ts +++ b/packages/pds/src/api/com/atproto/server/confirmEmail.ts @@ -9,7 +9,9 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ auth, input, req }) => { const did = auth.credentials.did - const user = await ctx.accountManager.getAccount(did) + const user = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + }) if (!user) { throw new InvalidRequestError('user not found', 'AccountNotFound') } diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index c8f47a0d432..1a9a4ef4570 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,6 +1,6 @@ import { DidDocument, MINUTE, check } from '@atproto/common' import { AtprotoData, ensureAtpDocument } from '@atproto/identity' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { ExportableKeypair, Keypair, Secp256k1Keypair } from '@atproto/crypto' import * as plc from '@did-plc/lib' import disposable from 'disposable-email' @@ -19,11 +19,21 @@ export default function (server: Server, ctx: AppContext) { durationMs: 5 * MINUTE, points: 100, }, - handler: async ({ input, req }) => { - const { did, handle, email, password, inviteCode, signingKey, plcOp } = - ctx.entrywayAgent - ? await validateInputsForEntrywayPds(ctx, input.body) - : await validateInputsForLocalPds(ctx, input.body) + auth: ctx.authVerifier.userDidAuthOptional, + handler: async ({ input, auth, req }) => { + const requester = auth.credentials?.iss ?? null + const { + did, + handle, + email, + password, + inviteCode, + signingKey, + plcOp, + deactivated, + } = ctx.entrywayAgent + ? await validateInputsForEntrywayPds(ctx, input.body) + : await validateInputsForLocalPds(ctx, input.body, requester) let didDoc: DidDocument | undefined let creds: { accessJwt: string; refreshJwt: string } @@ -54,9 +64,12 @@ export default function (server: Server, ctx: AppContext) { repoCid: commit.cid, repoRev: commit.rev, inviteCode, + deactivated, }) - await ctx.sequencer.sequenceCommit(did, commit, []) + if (!deactivated) { + await ctx.sequencer.sequenceCommit(did, commit, []) + } await ctx.accountManager.updateRepoRoot(did, commit.cid, commit.rev) didDoc = await didDocForSession(ctx, did, true) await ctx.actorStore.clearReservedKeypair(signingKey.did(), did) @@ -135,12 +148,14 @@ const validateInputsForEntrywayPds = async ( inviteCode: undefined, signingKey, plcOp, + deactivated: false, } } const validateInputsForLocalPds = async ( ctx: AppContext, input: CreateAccountInput, + requester: string | null, ) => { const { email, password, inviteCode } = input if (input.plcOp) { @@ -188,14 +203,38 @@ const validateInputsForLocalPds = async ( // determine the did & any plc ops we need to send // if the provided did document is poorly setup, we throw const signingKey = await Secp256k1Keypair.create({ exportable: true }) - const { did, plcOp } = input.did - ? await validateExistingDid(ctx, handle, input.did, signingKey) - : await createDidAndPlcOp(ctx, handle, input, signingKey) - return { did, handle, email, password, inviteCode, signingKey, plcOp } + let did: string + let plcOp: plc.Operation | null + let deactivated = false + if (input.did) { + if (input.did !== requester) { + throw new AuthRequiredError( + `Missing auth to create account with did: ${input.did}`, + ) + } + did = input.did + plcOp = null + deactivated = true + } else { + const formatted = await formatDidAndPlcOp(ctx, handle, input, signingKey) + did = formatted.did + plcOp = formatted.plcOp + } + + return { + did, + handle, + email, + password, + inviteCode, + signingKey, + plcOp, + deactivated, + } } -const createDidAndPlcOp = async ( +const formatDidAndPlcOp = async ( ctx: AppContext, handle: string, input: CreateAccountInput, @@ -224,47 +263,6 @@ const createDidAndPlcOp = async ( plcOp: plcCreate.op, } } - -const validateExistingDid = async ( - ctx: AppContext, - handle: string, - did: string, - signingKey: Keypair, -): Promise<{ - did: string - plcOp: plc.Operation | null -}> => { - // if the user is bringing their own did: - // resolve the user's did doc data, including rotationKeys if did:plc - // determine if we have the capability to make changes to their DID - let atpData: AtprotoData - try { - atpData = await ctx.idResolver.did.resolveAtprotoData(did) - } catch (err) { - throw new InvalidRequestError( - `could not resolve valid DID document :${did}`, - 'UnresolvableDid', - ) - } - validateAtprotoData(atpData, { - handle, - pds: ctx.cfg.service.publicUrl, - signingKey: signingKey.did(), - }) - - if (did.startsWith('did:plc')) { - const data = await ctx.plcClient.getDocumentData(did) - if (!data.rotationKeys.includes(ctx.plcRotationKey.did())) { - throw new InvalidRequestError( - 'PLC DID does not include service rotation key', - 'IncompatibleDidDoc', - ) - } - } - - return { did: did, plcOp: null } -} - const validateAtprotoData = ( data: AtprotoData, expected: { diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 70e42fdde62..c315f726f3a 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -35,8 +35,14 @@ export default function (server: Server, ctx: AppContext) { const identifier = input.body.identifier.toLowerCase() const user = identifier.includes('@') - ? await ctx.accountManager.getAccountByEmail(identifier, true) - : await ctx.accountManager.getAccount(identifier, true) + ? await ctx.accountManager.getAccountByEmail(identifier, { + includeDeactivated: true, + includeTakenDown: true, + }) + : await ctx.accountManager.getAccount(identifier, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!user) { throw new AuthRequiredError('Invalid identifier or password') diff --git a/packages/pds/src/api/com/atproto/server/deactivateAccount.ts b/packages/pds/src/api/com/atproto/server/deactivateAccount.ts new file mode 100644 index 00000000000..1b7b9179d9a --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/deactivateAccount.ts @@ -0,0 +1,15 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.deactivateAccount({ + auth: ctx.authVerifier.accessNotAppPassword, + handler: async ({ auth, input }) => { + const requester = auth.credentials.did + await ctx.accountManager.deactivateAccount( + requester, + input.body.deleteAfter ?? null, + ) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/deleteAccount.ts b/packages/pds/src/api/com/atproto/server/deleteAccount.ts index 9dc1de0df07..f4c3f59c170 100644 --- a/packages/pds/src/api/com/atproto/server/deleteAccount.ts +++ b/packages/pds/src/api/com/atproto/server/deleteAccount.ts @@ -13,7 +13,10 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input, req }) => { const { did, password, token } = input.body - const account = await ctx.accountManager.getAccount(did, true) + const account = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!account) { throw new InvalidRequestError('account not found') } diff --git a/packages/pds/src/api/com/atproto/server/getServiceAuth.ts b/packages/pds/src/api/com/atproto/server/getServiceAuth.ts new file mode 100644 index 00000000000..52bb0ff5ae9 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/getServiceAuth.ts @@ -0,0 +1,24 @@ +import { createServiceJwt } from '@atproto/xrpc-server' +import AppContext from '../../../../context' +import { Server } from '../../../../lexicon' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.getServiceAuth({ + auth: ctx.authVerifier.access, + handler: async ({ params, auth }) => { + const did = auth.credentials.did + const keypair = await ctx.actorStore.keypair(did) + const token = await createServiceJwt({ + iss: did, + aud: params.aud, + keypair, + }) + return { + encoding: 'application/json', + body: { + token, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/index.ts b/packages/pds/src/api/com/atproto/server/index.ts index f5ab1245c1c..7208f106b17 100644 --- a/packages/pds/src/api/com/atproto/server/index.ts +++ b/packages/pds/src/api/com/atproto/server/index.ts @@ -30,6 +30,11 @@ import createAppPassword from './createAppPassword' import listAppPasswords from './listAppPasswords' import revokeAppPassword from './revokeAppPassword' +import getServiceAuth from './getServiceAuth' +import checkAccountStatus from './checkAccountStatus' +import activateAccount from './activateAccount' +import deactivateAccount from './deactivateAccount' + export default function (server: Server, ctx: AppContext) { describeServer(server, ctx) createAccount(server, ctx) @@ -52,4 +57,8 @@ export default function (server: Server, ctx: AppContext) { createAppPassword(server, ctx) listAppPasswords(server, ctx) revokeAppPassword(server, ctx) + getServiceAuth(server, ctx) + checkAccountStatus(server, ctx) + activateAccount(server, ctx) + deactivateAccount(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 9b9d4f6a5fd..c33059c44e2 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -11,7 +11,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.refresh, handler: async ({ auth, req }) => { const did = auth.credentials.did - const user = await ctx.accountManager.getAccount(did, true) + const user = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!user) { throw new InvalidRequestError( `Could not find user info for account: ${did}`, diff --git a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts index e6ab60ce74b..5572c4c70d4 100644 --- a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts +++ b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts @@ -21,7 +21,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ auth, req }) => { const did = auth.credentials.did - const account = await ctx.accountManager.getAccount(did) + const account = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!account) { throw new InvalidRequestError('account not found') } diff --git a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts index ba031efab3f..1840f09c740 100644 --- a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts +++ b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts @@ -21,7 +21,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ auth, req }) => { const did = auth.credentials.did - const account = await ctx.accountManager.getAccount(did) + const account = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!account) { throw new InvalidRequestError('account not found') } diff --git a/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts index a2f7675576d..0972df7b77e 100644 --- a/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts +++ b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts @@ -21,7 +21,10 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ auth, req }) => { const did = auth.credentials.did - const account = await ctx.accountManager.getAccount(did) + const account = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!account) { throw new InvalidRequestError('account not found') } diff --git a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts index 0248f923f4c..d7684742ba8 100644 --- a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts +++ b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts @@ -19,7 +19,10 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ input, req }) => { const email = input.body.email.toLowerCase() - const account = await ctx.accountManager.getAccountByEmail(email) + const account = await ctx.accountManager.getAccountByEmail(email, { + includeDeactivated: true, + includeTakenDown: true, + }) if (!account?.email) { if (ctx.entrywayAgent) { diff --git a/packages/pds/src/api/com/atproto/server/updateEmail.ts b/packages/pds/src/api/com/atproto/server/updateEmail.ts index db6e3fcc25e..33de0ca6f61 100644 --- a/packages/pds/src/api/com/atproto/server/updateEmail.ts +++ b/packages/pds/src/api/com/atproto/server/updateEmail.ts @@ -16,7 +16,9 @@ export default function (server: Server, ctx: AppContext) { 'This email address is not supported, please use a different email.', ) } - const account = await ctx.accountManager.getAccount(did) + const account = await ctx.accountManager.getAccount(did, { + includeDeactivated: true, + }) if (!account) { throw new InvalidRequestError('account not found') } diff --git a/packages/pds/src/api/com/atproto/server/util.ts b/packages/pds/src/api/com/atproto/server/util.ts index fc3bfae8e05..43ec0398531 100644 --- a/packages/pds/src/api/com/atproto/server/util.ts +++ b/packages/pds/src/api/com/atproto/server/util.ts @@ -3,6 +3,8 @@ import { DidDocument } from '@atproto/identity' import { ServerConfig } from '../../../../config' import AppContext from '../../../../context' import { dbLogger } from '../../../../logger' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { getPdsEndpoint, getSigningDidKey } from '@atproto/common' // generate an invite code preceded by the hostname // with '.'s replaced by '-'s so it is not mistakable for a link @@ -40,3 +42,71 @@ export const didDocForSession = async ( dbLogger.warn({ err, did }, 'failed to resolve did doc') } } + +export const isValidDidDocForService = async ( + ctx: AppContext, + did: string, +): Promise => { + try { + await assertValidDidDocumentForService(ctx, did) + return true + } catch { + return false + } +} + +export const assertValidDidDocumentForService = async ( + ctx: AppContext, + did: string, +) => { + if (did.startsWith('did:plc')) { + const resolved = await ctx.plcClient.getDocumentData(did) + await assertValidDocContents(ctx, did, { + pdsEndpoint: resolved.services['atproto_pds']?.endpoint, + signingKey: resolved.verificationMethods['atproto'], + rotationKeys: resolved.rotationKeys, + }) + } else { + const resolved = await ctx.idResolver.did.resolve(did, true) + if (!resolved) { + throw new InvalidRequestError('Could not resolve DID') + } + await assertValidDocContents(ctx, did, { + pdsEndpoint: getPdsEndpoint(resolved), + signingKey: getSigningDidKey(resolved), + }) + } +} + +const assertValidDocContents = async ( + ctx: AppContext, + did: string, + contents: { + signingKey?: string + pdsEndpoint?: string + rotationKeys?: string[] + }, +) => { + const { signingKey, pdsEndpoint, rotationKeys } = contents + + const plcRotationKey = + ctx.cfg.entryway?.plcRotationKey ?? ctx.plcRotationKey.did() + if (rotationKeys !== undefined && !rotationKeys.includes(plcRotationKey)) { + throw new InvalidRequestError( + 'Server rotation key not included in PLC DID data', + ) + } + + if (!pdsEndpoint || pdsEndpoint !== ctx.cfg.service.publicUrl) { + throw new InvalidRequestError( + 'DID document atproto_pds service endpoint does not match PDS public url', + ) + } + + const keypair = await ctx.actorStore.keypair(did) + if (!signingKey || signingKey !== keypair.did()) { + throw new InvalidRequestError( + 'DID document verification method does not match expected signing key', + ) + } +} diff --git a/packages/pds/src/api/com/atproto/sync/listRepos.ts b/packages/pds/src/api/com/atproto/sync/listRepos.ts index 8a9fe8170a4..5641c8cfa68 100644 --- a/packages/pds/src/api/com/atproto/sync/listRepos.ts +++ b/packages/pds/src/api/com/atproto/sync/listRepos.ts @@ -13,6 +13,7 @@ export default function (server: Server, ctx: AppContext) { .selectFrom('actor') .innerJoin('repo_root', 'repo_root.did', 'actor.did') .where(notSoftDeletedClause(ref('actor'))) + .where('actor.deactivatedAt', 'is', null) .select([ 'actor.did as did', 'repo_root.cid as head', diff --git a/packages/pds/src/api/com/atproto/temp/importRepo.ts b/packages/pds/src/api/com/atproto/temp/importRepo.ts deleted file mode 100644 index ff11dd5f6d1..00000000000 --- a/packages/pds/src/api/com/atproto/temp/importRepo.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { Readable } from 'stream' -import assert from 'assert' -import PQueue from 'p-queue' -import axios from 'axios' -import { CID } from 'multiformats/cid' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { AsyncBuffer, TID, wait } from '@atproto/common' -import { AtUri } from '@atproto/syntax' -import { - Repo, - WriteOpAction, - getAndParseRecord, - readCarStream, - verifyDiff, -} from '@atproto/repo' -import { BlobRef, LexValue, RepoRecord } from '@atproto/lexicon' -import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' -import { ActorStoreTransactor } from '../../../../actor-store' -import { AtprotoData } from '@atproto/identity' - -export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.importRepo({ - opts: { - blobLimit: 5 * 1024 * 1024 * 1024, // 5GB - }, - auth: ctx.authVerifier.role, - handler: async ({ params, input, req }) => { - const { did } = params - const outBuffer = new AsyncBuffer() - sendTicks(outBuffer).catch((err) => { - req.log.error({ err }, 'failed to send ticks') - }) - processImport(ctx, did, input.body, outBuffer).catch(async (err) => { - req.log.error({ did, err }, 'failed import') - try { - await ctx.actorStore.destroy(did) - } catch (err) { - req.log.error({ did, err }, 'failed to clean up actor store') - } - outBuffer.throw(err) - }) - - return { - encoding: 'text/plain', - body: Readable.from(outBuffer.events()), - } - }, - }) -} - -const sendTicks = async (outBuffer: AsyncBuffer) => { - while (!outBuffer.isClosed) { - outBuffer.push('tick\n') - await wait(1000) - } -} - -const processImport = async ( - ctx: AppContext, - did: string, - incomingCar: AsyncIterable, - outBuffer: AsyncBuffer, -) => { - const didData = await ctx.idResolver.did.resolveAtprotoData(did) - const alreadyExists = await ctx.actorStore.exists(did) - if (!alreadyExists) { - const keypair = await ctx.actorStore.getReservedKeypair(did) - if (!keypair) { - throw new InvalidRequestError('No signing key reserved') - } - await ctx.actorStore.create(did, keypair) - } - await ctx.actorStore.transact(did, async (actorStore) => { - const blobRefs = await importRepo(actorStore, incomingCar, outBuffer) - await importBlobs(actorStore, didData, blobRefs, outBuffer) - }) - outBuffer.close() -} - -const importRepo = async ( - actorStore: ActorStoreTransactor, - incomingCar: AsyncIterable, - outBuffer: AsyncBuffer, -) => { - const now = new Date().toISOString() - const rev = TID.nextStr() - const did = actorStore.repo.did - - const { roots, blocks } = await readCarStream(incomingCar) - if (roots.length !== 1) { - throw new InvalidRequestError('expected one root') - } - outBuffer.push(`read ${blocks.size} blocks\n`) - const currRoot = await actorStore.db.db - .selectFrom('repo_root') - .selectAll() - .executeTakeFirst() - const currRepo = currRoot - ? await Repo.load(actorStore.repo.storage, CID.parse(currRoot.cid)) - : null - const diff = await verifyDiff( - currRepo, - blocks, - roots[0], - undefined, - undefined, - { ensureLeaves: false }, - ) - outBuffer.push(`diffed repo and found ${diff.writes.length} writes\n`) - diff.commit.rev = rev - await actorStore.repo.storage.applyCommit(diff.commit, currRepo === null) - const recordQueue = new PQueue({ concurrency: 50 }) - let blobRefs: BlobRef[] = [] - let count = 0 - for (const write of diff.writes) { - recordQueue.add(async () => { - const uri = AtUri.make(did, write.collection, write.rkey) - if (write.action === WriteOpAction.Delete) { - await actorStore.record.deleteRecord(uri) - } else { - let parsedRecord: RepoRecord | null - try { - const parsed = await getAndParseRecord(blocks, write.cid) - parsedRecord = parsed.record - } catch { - parsedRecord = null - } - const indexRecord = actorStore.record.indexRecord( - uri, - write.cid, - parsedRecord, - write.action, - rev, - now, - ) - const recordBlobs = findBlobRefs(parsedRecord) - blobRefs = blobRefs.concat(recordBlobs) - const blobValues = recordBlobs.map((cid) => ({ - recordUri: uri.toString(), - blobCid: cid.ref.toString(), - })) - const indexRecordBlobs = - blobValues.length > 0 - ? actorStore.db.db - .insertInto('record_blob') - .values(blobValues) - .onConflict((oc) => oc.doNothing()) - .execute() - : Promise.resolve() - await Promise.all([indexRecord, indexRecordBlobs]) - } - count++ - if (count % 50 === 0) { - outBuffer.push(`indexed ${count}/${diff.writes.length} writes\n`) - } - }) - } - outBuffer.push(`indexed ${count}/${diff.writes.length} writes\n`) - await recordQueue.onIdle() - return blobRefs -} - -const importBlobs = async ( - actorStore: ActorStoreTransactor, - didData: AtprotoData, - blobRefs: BlobRef[], - outBuffer: AsyncBuffer, -) => { - let blobCount = 0 - const blobQueue = new PQueue({ concurrency: 10 }) - outBuffer.push(`fetching ${blobRefs.length} blobs\n`) - const endpoint = `${didData.pds}/xrpc/com.atproto.sync.getBlob` - for (const ref of blobRefs) { - blobQueue.add(async () => { - try { - await importBlob(actorStore, endpoint, ref) - blobCount++ - outBuffer.push(`imported ${blobCount}/${blobRefs.length} blobs\n`) - } catch (err) { - outBuffer.push(`failed to import blob: ${ref.ref.toString()}\n`) - } - }) - } - await blobQueue.onIdle() - outBuffer.push(`finished importing all blobs\n`) -} - -const importBlob = async ( - actorStore: ActorStoreTransactor, - endpoint: string, - blob: BlobRef, -) => { - const hasBlob = await actorStore.db.db - .selectFrom('blob') - .selectAll() - .where('cid', '=', blob.ref.toString()) - .executeTakeFirst() - if (hasBlob) { - return - } - const res = await axios.get(endpoint, { - params: { did: actorStore.repo.did, cid: blob.ref.toString() }, - decompress: true, - responseType: 'stream', - timeout: 5000, - }) - const mimeType = res.headers['content-type'] ?? 'application/octet-stream' - const importedRef = await actorStore.repo.blob.addUntetheredBlob( - mimeType, - res.data, - ) - assert(blob.ref.equals(importedRef.ref)) - await actorStore.repo.blob.verifyBlobAndMakePermanent({ - mimeType: blob.mimeType, - cid: blob.ref, - constraints: {}, - }) -} - -export const findBlobRefs = (val: LexValue, layer = 0): BlobRef[] => { - if (layer > 10) { - return [] - } - // walk arrays - if (Array.isArray(val)) { - return val.flatMap((item) => findBlobRefs(item, layer + 1)) - } - // objects - if (val && typeof val === 'object') { - // convert blobs, leaving the original encoding so that we don't change CIDs on re-encode - if (val instanceof BlobRef) { - return [val] - } - // retain cids & bytes - if (CID.asCID(val) || val instanceof Uint8Array) { - return [] - } - return Object.values(val).flatMap((item) => findBlobRefs(item, layer + 1)) - } - // pass through - return [] -} diff --git a/packages/pds/src/api/com/atproto/temp/index.ts b/packages/pds/src/api/com/atproto/temp/index.ts index dbf249345f3..a39aef98fe9 100644 --- a/packages/pds/src/api/com/atproto/temp/index.ts +++ b/packages/pds/src/api/com/atproto/temp/index.ts @@ -1,13 +1,7 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import checkSignupQueue from './checkSignupQueue' -import importRepo from './importRepo' -import pushBlob from './pushBlob' -import transferAccount from './transferAccount' export default function (server: Server, ctx: AppContext) { checkSignupQueue(server, ctx) - importRepo(server, ctx) - pushBlob(server, ctx) - transferAccount(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/temp/pushBlob.ts b/packages/pds/src/api/com/atproto/temp/pushBlob.ts deleted file mode 100644 index 74ef80e42c0..00000000000 --- a/packages/pds/src/api/com/atproto/temp/pushBlob.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' - -export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.pushBlob({ - auth: ctx.authVerifier.role, - handler: async ({ params, input }) => { - const { did } = params - - await ctx.actorStore.transact(did, async (actorTxn) => { - const blob = await actorTxn.repo.blob.addUntetheredBlob( - input.encoding, - input.body, - ) - await actorTxn.repo.blob.verifyBlobAndMakePermanent({ - mimeType: blob.mimeType, - cid: blob.ref, - constraints: {}, - }) - }) - }, - }) -} diff --git a/packages/pds/src/api/com/atproto/temp/transferAccount.ts b/packages/pds/src/api/com/atproto/temp/transferAccount.ts deleted file mode 100644 index 0b1b765089c..00000000000 --- a/packages/pds/src/api/com/atproto/temp/transferAccount.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { ensureAtpDocument } from '@atproto/identity' -import * as plc from '@did-plc/lib' -import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' -import { check, cidForCbor } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { BlockMap, CidSet } from '@atproto/repo' - -export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.transferAccount({ - auth: ctx.authVerifier.role, - handler: async ({ input }) => { - const { did, handle } = input.body - - const signingKey = await ctx.actorStore.keypair(did) - const currRoot = await ctx.actorStore.read(did, (store) => - store.repo.storage.getRootDetailed(), - ) - - const plcOp = did.startsWith('did:plc') - ? await verifyDidAndPlcOp( - ctx, - did, - handle, - signingKey.did(), - input.body.plcOp, - ) - : null - - const { accessJwt, refreshJwt } = await ctx.accountManager.createAccount({ - did, - handle, - repoCid: currRoot.cid, - repoRev: currRoot.rev, - }) - - if (plcOp) { - try { - await ctx.plcClient.sendOperation(did, plcOp) - } catch (err) { - await ctx.accountManager.deleteAccount(did) - throw err - } - } - - await ctx.sequencer.sequenceCommit( - did, - { - cid: currRoot.cid, - rev: currRoot.rev, - since: null, - prev: null, - newBlocks: new BlockMap(), - removedCids: new CidSet(), - }, - [], - ) - - return { - encoding: 'application/json', - body: { - handle, - did: did, - accessJwt: accessJwt, - refreshJwt: refreshJwt, - }, - } - }, - }) -} - -const verifyDidAndPlcOp = async ( - ctx: AppContext, - did: string, - handle: string, - signingKey: string, - plcOp: unknown, -): Promise => { - if (!check.is(plcOp, plc.def.operation)) { - throw new InvalidRequestError('invalid plc operation', 'IncompatibleDidDoc') - } - await plc.assureValidOp(plcOp) - const prev = await ctx.plcClient.getLastOp(did) - if (!prev || prev.type === 'plc_tombstone') { - throw new InvalidRequestError( - 'no accessible prev for did', - 'IncompatibleDidDoc', - ) - } - const prevCid = await cidForCbor(prev) - if (plcOp.prev?.toString() !== prevCid.toString()) { - throw new InvalidRequestError( - 'invalid prev on plc operation', - 'IncompatibleDidDoc', - ) - } - const normalizedPrev = plc.normalizeOp(prev) - await plc.assureValidSig(normalizedPrev.rotationKeys, plcOp) - const doc = plc.formatDidDoc({ did, ...plcOp }) - const data = ensureAtpDocument(doc) - if (handle !== data.handle) { - throw new InvalidRequestError( - 'invalid handle on plc operation', - 'IncompatibleDidDoc', - ) - } else if (data.pds !== ctx.cfg.service.publicUrl) { - throw new InvalidRequestError( - 'invalid service on plc operation', - 'IncompatibleDidDoc', - ) - } else if (data.signingKey !== signingKey) { - throw new InvalidRequestError( - 'invalid signing key on plc operation', - 'IncompatibleDidDoc', - ) - } - return plcOp -} diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index b24f530ade3..9d1f7b8bd19 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -73,6 +73,14 @@ type RefreshOutput = { artifacts: string } +type UserDidOutput = { + credentials: { + type: 'user_did' + aud: string + iss: string + } +} + type ValidatedBearer = { did: string scope: AuthScope @@ -126,7 +134,9 @@ export class AuthVerifier { AuthScope.Access, AuthScope.AppPass, ]) - const found = await this.accountManager.getAccount(result.credentials.did) + const found = await this.accountManager.getAccount(result.credentials.did, { + includeDeactivated: true, + }) if (!found) { // will be turned into ExpiredToken for the client if proxied by entryway throw new ForbiddenError('Account not found', 'AccountNotFound') @@ -219,24 +229,35 @@ export class AuthVerifier { } } - adminService = async (reqCtx: ReqCtx): Promise => { - const jwtStr = bearerTokenFromReq(reqCtx.req) - if (!jwtStr) { - throw new AuthRequiredError('missing jwt', 'MissingJwt') - } - const payload = await verifyServiceJwt( - jwtStr, - this.dids.entryway ?? this.dids.pds, - async (did, forceRefresh) => { - if (did !== this.dids.admin) { - throw new AuthRequiredError( - 'Untrusted issuer for admin actions', - 'UntrustedIss', - ) - } - return this.idResolver.did.resolveAtprotoKey(did, forceRefresh) + userDidAuth = async (reqCtx: ReqCtx): Promise => { + const payload = await this.verifyServiceJwt(reqCtx, { + aud: this.dids.entryway ?? this.dids.pds, + iss: null, + }) + return { + credentials: { + type: 'user_did', + aud: payload.aud, + iss: payload.iss, }, - ) + } + } + + userDidAuthOptional = async ( + reqCtx: ReqCtx, + ): Promise => { + if (isBearerToken(reqCtx.req)) { + return await this.userDidAuth(reqCtx) + } else { + return { credentials: null } + } + } + + adminService = async (reqCtx: ReqCtx): Promise => { + const payload = await this.verifyServiceJwt(reqCtx, { + aud: this.dids.entryway ?? this.dids.pds, + iss: [this.dids.admin], + }) return { credentials: { type: 'service', @@ -308,6 +329,28 @@ export class AuthVerifier { } } + async verifyServiceJwt( + reqCtx: ReqCtx, + opts: { aud: string | null; iss: string[] | null }, + ) { + const getSigningKey = async ( + did: string, + forceRefresh: boolean, + ): Promise => { + if (opts.iss !== null && !opts.iss.includes(did)) { + throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') + } + return this.idResolver.did.resolveAtprotoKey(did, forceRefresh) + } + + const jwtStr = bearerTokenFromReq(reqCtx.req) + if (!jwtStr) { + throw new AuthRequiredError('missing jwt', 'MissingJwt') + } + const payload = await verifyServiceJwt(jwtStr, opts.aud, getSigningKey) + return { iss: payload.iss, aud: payload.aud } + } + parseRoleCreds(req: express.Request) { const parsed = parseBasicAuth(req.headers.authorization || '') const { Missing, Valid, Invalid } = RoleStatus diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 2f8f295b2b0..34f113df985 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -22,6 +22,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { version: env.version, // default? privacyPolicyUrl: env.privacyPolicyUrl, termsOfServiceUrl: env.termsOfServiceUrl, + acceptingImports: env.acceptingImports ?? true, } const dbLoc = (name: string) => { @@ -245,6 +246,7 @@ export type ServiceConfig = { publicUrl: string did: string version?: string + acceptingImports: boolean privacyPolicyUrl?: string termsOfServiceUrl?: string } diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index cc5f698fa80..d096cda994e 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -9,6 +9,7 @@ export const readEnv = (): ServerEnvironment => { version: envStr('PDS_VERSION'), privacyPolicyUrl: envStr('PDS_PRIVACY_POLICY_URL'), termsOfServiceUrl: envStr('PDS_TERMS_OF_SERVICE_URL'), + acceptingImports: envBool('PDS_ACCEPTING_REPO_IMPORTS'), // database dataDirectory: envStr('PDS_DATA_DIRECTORY'), @@ -110,6 +111,7 @@ export type ServerEnvironment = { version?: string privacyPolicyUrl?: string termsOfServiceUrl?: string + acceptingImports?: boolean // database dataDirectory?: string diff --git a/packages/pds/src/db/util.ts b/packages/pds/src/db/util.ts index 17b84822753..519921c70dc 100644 --- a/packages/pds/src/db/util.ts +++ b/packages/pds/src/db/util.ts @@ -22,6 +22,7 @@ export const softDeleted = (repoOrRecord: { takedownRef: string | null }) => { } export const countAll = sql`count(*)` +export const countDistinct = (ref: DbRef) => sql`count(distinct ${ref})` // For use with doUpdateSet() export const excluded = (db: Kysely, col) => { diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index ed08ff55702..6b3b6de582a 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -90,10 +90,7 @@ import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCra import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' import * as ComAtprotoTempCheckSignupQueue from './types/com/atproto/temp/checkSignupQueue' import * as ComAtprotoTempFetchLabels from './types/com/atproto/temp/fetchLabels' -import * as ComAtprotoTempImportRepo from './types/com/atproto/temp/importRepo' -import * as ComAtprotoTempPushBlob from './types/com/atproto/temp/pushBlob' import * as ComAtprotoTempRequestPhoneVerification from './types/com/atproto/temp/requestPhoneVerification' -import * as ComAtprotoTempTransferAccount from './types/com/atproto/temp/transferAccount' import * as AppBskyActorGetPreferences from './types/app/bsky/actor/getPreferences' import * as AppBskyActorGetProfile from './types/app/bsky/actor/getProfile' import * as AppBskyActorGetProfiles from './types/app/bsky/actor/getProfiles' @@ -1167,28 +1164,6 @@ export class ComAtprotoTempNS { return this._server.xrpc.method(nsid, cfg) } - importRepo( - cfg: ConfigOf< - AV, - ComAtprotoTempImportRepo.Handler>, - ComAtprotoTempImportRepo.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.importRepo' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - - pushBlob( - cfg: ConfigOf< - AV, - ComAtprotoTempPushBlob.Handler>, - ComAtprotoTempPushBlob.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.pushBlob' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - requestPhoneVerification( cfg: ConfigOf< AV, @@ -1199,17 +1174,6 @@ export class ComAtprotoTempNS { const nsid = 'com.atproto.temp.requestPhoneVerification' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } - - transferAccount( - cfg: ConfigOf< - AV, - ComAtprotoTempTransferAccount.Handler>, - ComAtprotoTempTransferAccount.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.transferAccount' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } } export class AppNS { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index a3491462d50..6164b1706a3 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -4853,59 +4853,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempImportRepo: { - lexicon: 1, - id: 'com.atproto.temp.importRepo', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: 'application/vnd.ipld.car', - }, - output: { - encoding: 'text/plain', - }, - }, - }, - }, - ComAtprotoTempPushBlob: { - lexicon: 1, - id: 'com.atproto.temp.pushBlob', - defs: { - main: { - type: 'procedure', - description: - "Gets the did's repo, optionally catching up from a specific revision.", - parameters: { - type: 'params', - required: ['did'], - properties: { - did: { - type: 'string', - format: 'did', - description: 'The DID of the repo.', - }, - }, - }, - input: { - encoding: '*/*', - }, - }, - }, - }, ComAtprotoTempRequestPhoneVerification: { lexicon: 1, id: 'com.atproto.temp.requestPhoneVerification', @@ -4929,83 +4876,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempTransferAccount: { - lexicon: 1, - id: 'com.atproto.temp.transferAccount', - defs: { - main: { - type: 'procedure', - description: - 'Transfer an account. NOTE: temporary method, necessarily how account migration will be implemented.', - input: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['handle', 'did', 'plcOp'], - properties: { - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - plcOp: { - type: 'unknown', - }, - }, - }, - }, - output: { - encoding: 'application/json', - schema: { - type: 'object', - required: ['accessJwt', 'refreshJwt', 'handle', 'did'], - properties: { - accessJwt: { - type: 'string', - }, - refreshJwt: { - type: 'string', - }, - handle: { - type: 'string', - format: 'handle', - }, - did: { - type: 'string', - format: 'did', - }, - }, - }, - }, - errors: [ - { - name: 'InvalidHandle', - }, - { - name: 'InvalidPassword', - }, - { - name: 'InvalidInviteCode', - }, - { - name: 'HandleNotAvailable', - }, - { - name: 'UnsupportedDomain', - }, - { - name: 'UnresolvableDid', - }, - { - name: 'IncompatibleDidDoc', - }, - ], - }, - }, - }, AppBskyActorDefs: { lexicon: 1, id: 'app.bsky.actor.defs', @@ -8984,11 +8854,8 @@ export const ids = { ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempCheckSignupQueue: 'com.atproto.temp.checkSignupQueue', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', - ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', - ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', - ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', AppBskyActorGetProfile: 'app.bsky.actor.getProfile', diff --git a/packages/pds/src/lexicon/types/com/atproto/temp/importRepo.ts b/packages/pds/src/lexicon/types/com/atproto/temp/importRepo.ts deleted file mode 100644 index 44bce41481c..00000000000 --- a/packages/pds/src/lexicon/types/com/atproto/temp/importRepo.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import express from 'express' -import stream from 'stream' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { lexicons } from '../../../../lexicons' -import { isObj, hasProp } from '../../../../util' -import { CID } from 'multiformats/cid' -import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface HandlerInput { - encoding: 'application/vnd.ipld.car' - body: stream.Readable -} - -export interface HandlerSuccess { - encoding: 'text/plain' - body: Uint8Array | stream.Readable - headers?: { [key: string]: string } -} - -export interface HandlerError { - status: number - message?: string -} - -export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough -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/temp/pushBlob.ts b/packages/pds/src/lexicon/types/com/atproto/temp/pushBlob.ts deleted file mode 100644 index d18a60b598f..00000000000 --- a/packages/pds/src/lexicon/types/com/atproto/temp/pushBlob.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import express from 'express' -import stream from 'stream' -import { ValidationResult, BlobRef } from '@atproto/lexicon' -import { lexicons } from '../../../../lexicons' -import { isObj, hasProp } from '../../../../util' -import { CID } from 'multiformats/cid' -import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams { - /** The DID of the repo. */ - did: string -} - -export type InputSchema = string | Uint8Array - -export interface HandlerInput { - encoding: '*/*' - body: stream.Readable -} - -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/temp/transferAccount.ts b/packages/pds/src/lexicon/types/com/atproto/temp/transferAccount.ts deleted file mode 100644 index 565150caba2..00000000000 --- a/packages/pds/src/lexicon/types/com/atproto/temp/transferAccount.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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, HandlerPipeThrough } from '@atproto/xrpc-server' - -export interface QueryParams {} - -export interface InputSchema { - handle: string - did: string - plcOp: {} - [k: string]: unknown -} - -export interface OutputSchema { - accessJwt: string - refreshJwt: string - handle: string - did: string - [k: string]: unknown -} - -export interface HandlerInput { - encoding: 'application/json' - body: InputSchema -} - -export interface HandlerSuccess { - encoding: 'application/json' - body: OutputSchema - headers?: { [key: string]: string } -} - -export interface HandlerError { - status: number - message?: string - error?: - | 'InvalidHandle' - | 'InvalidPassword' - | 'InvalidInviteCode' - | 'HandleNotAvailable' - | 'UnsupportedDomain' - | 'UnresolvableDid' - | 'IncompatibleDidDoc' -} - -export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough -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 df539ac03b9..f6c57cec40e 100644 --- a/packages/pds/src/mailer/index.ts +++ b/packages/pds/src/mailer/index.ts @@ -53,6 +53,13 @@ export class ServerMailer { }) } + async sendPlcOperation(params: { token: string }, mailOpts: Mail.Options) { + return this.sendTemplate('plcOperation', params, { + subject: 'PLC Update Operation Requested', + ...mailOpts, + }) + } + private async sendTemplate(templateName, params, mailOpts: Mail.Options) { const html = this.templates[templateName]({ ...params, diff --git a/packages/pds/src/mailer/templates.ts b/packages/pds/src/mailer/templates.ts index 08c3f3883fb..86a683053f9 100644 --- a/packages/pds/src/mailer/templates.ts +++ b/packages/pds/src/mailer/templates.ts @@ -5,3 +5,4 @@ export { default as resetPassword } from './templates/reset-password.hbs' export { default as deleteAccount } from './templates/delete-account.hbs' export { default as confirmEmail } from './templates/confirm-email.hbs' export { default as updateEmail } from './templates/update-email.hbs' +export { default as plcOperation } from './templates/plc-operation.hbs' diff --git a/packages/pds/src/mailer/templates/plc-operation.hbs b/packages/pds/src/mailer/templates/plc-operation.hbs new file mode 100644 index 00000000000..2d998245202 --- /dev/null +++ b/packages/pds/src/mailer/templates/plc-operation.hbs @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/pds/src/well-known.ts b/packages/pds/src/well-known.ts index d1c6169cac9..a2c0fe4a7b1 100644 --- a/packages/pds/src/well-known.ts +++ b/packages/pds/src/well-known.ts @@ -14,7 +14,7 @@ export const createRouter = (ctx: AppContext): express.Router => { } let did: string | undefined try { - const user = await ctx.accountManager.getAccount(handle, true) + const user = await ctx.accountManager.getAccount(handle) did = user?.did } catch (err) { return res.status(500).send('Internal Server Error') diff --git a/packages/pds/tests/account-deactivation.test.ts b/packages/pds/tests/account-deactivation.test.ts new file mode 100644 index 00000000000..83820f24e49 --- /dev/null +++ b/packages/pds/tests/account-deactivation.test.ts @@ -0,0 +1,148 @@ +import AtpAgent from '@atproto/api' +import { SeedClient, TestNetworkNoAppView, basicSeed } from '@atproto/dev-env' + +describe('account deactivation', () => { + let network: TestNetworkNoAppView + + let sc: SeedClient + let agent: AtpAgent + + let alice: string + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'account_deactivation', + }) + + sc = network.getSeedClient() + agent = network.pds.getClient() + + await basicSeed(sc) + alice = sc.dids.alice + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + it('deactivates account', async () => { + await agent.com.atproto.server.deactivateAccount( + {}, + { encoding: 'application/json', headers: sc.getHeaders(alice) }, + ) + }) + + it('no longer serves repo data', async () => { + await expect( + agent.com.atproto.sync.getRepo({ did: alice }), + ).rejects.toThrow() + await expect( + agent.com.atproto.sync.getLatestCommit({ did: alice }), + ).rejects.toThrow() + await expect( + agent.com.atproto.sync.listBlobs({ did: alice }), + ).rejects.toThrow() + const recordUri = sc.posts[alice][0].ref.uri + await expect( + agent.com.atproto.sync.getRecord({ + did: alice, + collection: recordUri.collection, + rkey: recordUri.rkey, + }), + ).rejects.toThrow() + await expect( + agent.com.atproto.repo.getRecord({ + repo: alice, + collection: recordUri.collection, + rkey: recordUri.rkey, + }), + ).rejects.toThrow() + await expect( + agent.com.atproto.repo.describeRepo({ + repo: alice, + }), + ).rejects.toThrow() + + const blobCid = sc.profiles[alice].avatar.cid + await expect( + agent.com.atproto.sync.getBlob({ + did: alice, + cid: blobCid, + }), + ).rejects.toThrow() + const listedRepos = await agent.com.atproto.sync.listRepos() + expect(listedRepos.data.repos.find((r) => r.did === alice)).toBeUndefined() + }) + + it('no longer resolves handle', async () => { + await expect( + agent.com.atproto.identity.resolveHandle({ + handle: sc.accounts[alice].handle, + }), + ).rejects.toThrow() + }) + + it('still allows login', async () => { + await agent.com.atproto.server.createSession({ + identifier: alice, + password: sc.accounts[alice].password, + }) + }) + + it('does not allow writes', async () => { + const createAttempt = agent.com.atproto.repo.createRecord( + { + repo: alice, + collection: 'app.bsky.feed.post', + record: { + text: 'blah', + createdAt: new Date().toISOString(), + }, + }, + { + encoding: 'application/json', + headers: sc.getHeaders(alice), + }, + ) + const uri = sc.posts[alice][0].ref.uri + await expect(createAttempt).rejects.toThrow('Account is deactivated') + + const putAttempt = agent.com.atproto.repo.putRecord( + { + repo: alice, + collection: uri.collection, + rkey: uri.rkey, + record: { + text: 'blah', + createdAt: new Date().toISOString(), + }, + }, + { + encoding: 'application/json', + headers: sc.getHeaders(alice), + }, + ) + await expect(putAttempt).rejects.toThrow('Account is deactivated') + + const deleteAttempt = agent.com.atproto.repo.deleteRecord( + { + repo: alice, + collection: uri.collection, + rkey: uri.rkey, + }, + { + encoding: 'application/json', + headers: sc.getHeaders(alice), + }, + ) + await expect(deleteAttempt).rejects.toThrow('Account is deactivated') + }) + + it('reactivates', async () => { + await agent.com.atproto.server.activateAccount(undefined, { + headers: sc.getHeaders(alice), + }) + await agent.com.atproto.sync.getRepo({ did: alice }) + }) +}) diff --git a/packages/pds/tests/account-migration.test.ts b/packages/pds/tests/account-migration.test.ts new file mode 100644 index 00000000000..beb14599e38 --- /dev/null +++ b/packages/pds/tests/account-migration.test.ts @@ -0,0 +1,219 @@ +import AtpAgent, { AtUri } from '@atproto/api' +import { + SeedClient, + TestNetworkNoAppView, + TestPds, + mockNetworkUtilities, +} from '@atproto/dev-env' +import { readCar } from '@atproto/repo' +import assert from 'assert' + +describe('account migration', () => { + let network: TestNetworkNoAppView + let newPds: TestPds + + let sc: SeedClient + let oldAgent: AtpAgent + let newAgent: AtpAgent + + let alice: string + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'account_migration', + }) + newPds = await TestPds.create({ + didPlcUrl: network.plc.url, + }) + mockNetworkUtilities(newPds) + + sc = network.getSeedClient() + oldAgent = network.pds.getClient() + newAgent = newPds.getClient() + + await sc.createAccount('alice', { + email: 'alice@test.com', + handle: 'alice.test', + password: 'alice-pass', + }) + alice = sc.dids.alice + + for (let i = 0; i < 100; i++) { + await sc.post(alice, 'test post') + } + const img1 = await sc.uploadFile( + alice, + '../dev-env/src/seed/img/at.png', + 'image/png', + ) + const img2 = await sc.uploadFile( + alice, + '../dev-env/src/seed/img/key-alt.jpg', + 'image/jpeg', + ) + const img3 = await sc.uploadFile( + alice, + '../dev-env/src/seed/img/key-landscape-small.jpg', + 'image/jpeg', + ) + + await sc.post(alice, 'test', undefined, [img1]) + await sc.post(alice, 'test', undefined, [img1, img2]) + await sc.post(alice, 'test', undefined, [img3]) + + await network.processAll() + + await oldAgent.login({ + identifier: sc.accounts[alice].handle, + password: sc.accounts[alice].password, + }) + }) + + afterAll(async () => { + await newPds.close() + await network.close() + }) + + it('migrates an account', async () => { + const describeRes = await newAgent.api.com.atproto.server.describeServer() + const newServerDid = describeRes.data.did + + const serviceJwtRes = await oldAgent.com.atproto.server.getServiceAuth({ + aud: newServerDid, + }) + const serviceJwt = serviceJwtRes.data.token + + await newAgent.api.com.atproto.server.createAccount( + { + handle: 'new-alice.test', + email: 'alice@test.com', + password: 'alice-pass', + did: alice, + }, + { + headers: { authorization: `Bearer ${serviceJwt}` }, + encoding: 'application/json', + }, + ) + await newAgent.login({ + identifier: 'new-alice.test', + password: 'alice-pass', + }) + + const statusRes1 = await newAgent.com.atproto.server.checkAccountStatus() + expect(statusRes1.data).toMatchObject({ + activated: false, + validDid: false, + repoBlocks: 2, // commit & empty data root + indexedRecords: 0, + privateStateValues: 0, + expectedBlobs: 0, + importedBlobs: 0, + }) + + const repoRes = await oldAgent.com.atproto.sync.getRepo({ did: alice }) + const carBlocks = await readCar(repoRes.data) + + await newAgent.com.atproto.repo.importRepo(repoRes.data, { + encoding: 'application/vnd.ipld.car', + }) + + const statusRes2 = await newAgent.com.atproto.server.checkAccountStatus() + expect(statusRes2.data).toMatchObject({ + activated: false, + validDid: false, + indexedRecords: 103, + privateStateValues: 0, + expectedBlobs: 3, + importedBlobs: 0, + }) + expect(statusRes2.data.repoBlocks).toBe(carBlocks.blocks.size) + + const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs() + expect(missingBlobs.data.blobs.length).toBe(3) + + let blobCursor: string | undefined = undefined + do { + const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ + did: alice, + cursor: blobCursor, + }) + for (const cid of listedBlobs.data.cids) { + const blobRes = await oldAgent.com.atproto.sync.getBlob({ + did: alice, + cid, + }) + await newAgent.com.atproto.repo.uploadBlob(blobRes.data, { + encoding: blobRes.headers['content-type'], + }) + } + blobCursor = listedBlobs.data.cursor + } while (blobCursor) + + const statusRes3 = await newAgent.com.atproto.server.checkAccountStatus() + expect(statusRes3.data.expectedBlobs).toBe(3) + expect(statusRes3.data.importedBlobs).toBe(3) + + const prefs = await oldAgent.api.app.bsky.actor.getPreferences() + await newAgent.api.app.bsky.actor.putPreferences(prefs.data) + + const getDidCredentials = + await newAgent.com.atproto.identity.getRecommendedDidCredentials() + + await oldAgent.com.atproto.identity.requestPlcOperationSignature() + const res = await network.pds.ctx.accountManager.db.db + .selectFrom('email_token') + .selectAll() + .where('did', '=', alice) + .where('purpose', '=', 'plc_operation') + .executeTakeFirst() + const token = res?.token + assert(token) + + const plcOp = await oldAgent.com.atproto.identity.signPlcOperation({ + token, + ...getDidCredentials.data, + }) + + await newAgent.com.atproto.identity.submitPlcOperation({ + operation: plcOp.data.operation, + }) + + await newAgent.com.atproto.server.activateAccount() + + const statusRes4 = await newAgent.com.atproto.server.checkAccountStatus() + expect(statusRes4.data).toMatchObject({ + activated: true, + validDid: true, + indexedRecords: 103, + privateStateValues: 0, + expectedBlobs: 3, + importedBlobs: 3, + }) + + await oldAgent.com.atproto.server.deactivateAccount({}) + + const statusResOldPds = + await oldAgent.com.atproto.server.checkAccountStatus() + expect(statusResOldPds.data).toMatchObject({ + activated: false, + validDid: false, + }) + + const postRes = await newAgent.api.app.bsky.feed.post.create( + { repo: alice }, + { + text: 'new pds!', + createdAt: new Date().toISOString(), + }, + ) + const postUri = new AtUri(postRes.uri) + const fetchedPost = await newAgent.api.app.bsky.feed.post.get({ + repo: postUri.hostname, + rkey: postUri.rkey, + }) + expect(fetchedPost.value.text).toEqual('new pds!') + const statusRes5 = await newAgent.com.atproto.server.checkAccountStatus() + expect(statusRes5.data.indexedRecords).toBe(104) + }) +}) diff --git a/packages/pds/tests/admin-auth.test.ts b/packages/pds/tests/admin-auth.test.ts index 4cf7d5c26a5..dffd9261874 100644 --- a/packages/pds/tests/admin-auth.test.ts +++ b/packages/pds/tests/admin-auth.test.ts @@ -96,7 +96,7 @@ describe('admin auth', () => { encoding: 'application/json', }, ) - await expect(attempt).rejects.toThrow('Untrusted issuer for admin actions') + await expect(attempt).rejects.toThrow('Untrusted issuer') }) it('does not allow requests with a bad signature', async () => { diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts index d27c49c585c..8ef77239a4d 100644 --- a/packages/pds/tests/entryway.test.ts +++ b/packages/pds/tests/entryway.test.ts @@ -145,13 +145,11 @@ describe('entryway', () => { pds: pds.ctx.cfg.service.publicUrl, signer: rotationKey, }) - const tryCreateAccount = pdsAgent.api.com.atproto.server.createAccount( - { did: plcCreate.did, plcOp: plcCreate.op, handle: 'weirdalice.test' }, - { - headers: SeedClient.getHeaders(accessToken), - encoding: 'application/json', - }, - ) + const tryCreateAccount = pdsAgent.api.com.atproto.server.createAccount({ + did: plcCreate.did, + plcOp: plcCreate.op, + handle: 'weirdalice.test', + }) await expect(tryCreateAccount).rejects.toThrow('invalid plc operation') }) }) diff --git a/packages/pds/tests/moderation.test.ts b/packages/pds/tests/moderation.test.ts index ba58bcc70eb..4d3acfe2e25 100644 --- a/packages/pds/tests/moderation.test.ts +++ b/packages/pds/tests/moderation.test.ts @@ -198,14 +198,19 @@ describe('moderation', () => { }) it('prevents blob from being referenced again.', async () => { - const uploaded = await sc.uploadFile( + const referenceBlob = sc.post(sc.dids.carol, 'pic', [], [blobRef]) + await expect(referenceBlob).rejects.toThrow('Could not find blob:') + }) + + it('prevents blob from being reuploaded', async () => { + const attempt = sc.uploadFile( sc.dids.carol, '../dev-env/src/seed/img/key-alt.jpg', 'image/jpeg', ) - expect(uploaded.image.ref.equals(blobRef.image.ref)).toBeTruthy() - const referenceBlob = sc.post(sc.dids.carol, 'pic', [], [blobRef]) - await expect(referenceBlob).rejects.toThrow('Could not find blob:') + await expect(attempt).rejects.toThrow( + 'Blob has been takendown, cannot re-upload', + ) }) it('prevents image blob from being served.', async () => { diff --git a/packages/pds/tests/plc-operations.test.ts b/packages/pds/tests/plc-operations.test.ts new file mode 100644 index 00000000000..9c4ed9002cb --- /dev/null +++ b/packages/pds/tests/plc-operations.test.ts @@ -0,0 +1,226 @@ +import AtpAgent from '@atproto/api' +import { Secp256k1Keypair } from '@atproto/crypto' +import { SeedClient, TestNetworkNoAppView, basicSeed } from '@atproto/dev-env' +import * as plc from '@did-plc/lib' +import assert from 'assert' +import { once } from 'events' +import Mail from 'nodemailer/lib/mailer' +import { EventEmitter } from 'stream' +import { AppContext } from '../src' +import { check } from '@atproto/common' + +describe('plc operations', () => { + let network: TestNetworkNoAppView + let ctx: AppContext + let agent: AtpAgent + let sc: SeedClient + + const mailCatcher = new EventEmitter() + let _origSendMail + + let alice: string + + let sampleKey: string + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'plc_operations', + }) + ctx = network.pds.ctx + const mailer = ctx.mailer + + sc = network.getSeedClient() + agent = network.pds.getClient() + + await basicSeed(sc) + alice = sc.dids.alice + await network.processAll() + + sampleKey = (await Secp256k1Keypair.create()).did() + + // 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 () => { + 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 lastOp = await ctx.plcClient.getLastOp(did) + if (check.is(lastOp, plc.def.tombstone)) { + throw new Error('Did is tombstoned') + } + return plc.createUpdateOp(lastOp, ctx.plcRotationKey, (lastOp) => ({ + ...lastOp, + rotationKeys: op.rotationKeys ?? lastOp.rotationKeys, + alsoKnownAs: op.alsoKnownAs ?? lastOp.alsoKnownAs, + verificationMethods: op.verificationMethods ?? lastOp.verificationMethods, + services: op.services ?? lastOp.services, + })) + } + + const expectFailedOp = async ( + did: string, + op: Partial, + expectedErr?: string, + ) => { + const operation = await signOp(did, op) + const attempt = agent.com.atproto.identity.submitPlcOperation( + { operation }, + { + encoding: 'application/json', + headers: sc.getHeaders(alice), + }, + ) + await expect(attempt).rejects.toThrow(expectedErr) + } + + it("prevents submitting an operation that removes the server's rotation key", async () => { + await expectFailedOp( + alice, + { rotationKeys: [sampleKey] }, + "Rotation keys do not include server's rotation key", + ) + }) + + it('prevents submitting an operation that incorrectly sets the signing key', async () => { + await expectFailedOp( + alice, + { + verificationMethods: { + atproto: sampleKey, + }, + }, + 'Incorrect signing key', + ) + }) + + it('prevents submitting an operation that incorrectly sets the handle', async () => { + await expectFailedOp( + alice, + { + alsoKnownAs: ['at://new-alice.test'], + }, + 'Incorrect handle in alsoKnownAs', + ) + }) + + it('prevents submitting an operation that incorrectly sets the pds endpoint', async () => { + await expectFailedOp( + alice, + { + services: { + atproto_pds: { + type: 'AtprotoPersonalDataServer', + endpoint: 'https://example.com', + }, + }, + }, + 'Incorrect endpoint on atproto_pds service', + ) + }) + + it('prevents submitting an operation that incorrectly sets the pds service type', async () => { + await expectFailedOp( + alice, + { + services: { + atproto_pds: { + type: 'NotAPersonalDataServer', + endpoint: ctx.cfg.service.publicUrl, + }, + }, + }, + 'Incorrect type on atproto_pds service', + ) + }) + + it('does not allow signing plc operation without a token', async () => { + const attempt = agent.com.atproto.identity.signPlcOperation( + { + rotationKeys: [sampleKey], + }, + { encoding: 'application/json', headers: sc.getHeaders(alice) }, + ) + await expect(attempt).rejects.toThrow( + 'email confirmation token required to sign PLC operations', + ) + }) + + let token: string + + it('requests a plc signature', async () => { + const mail = await getMailFrom( + agent.api.com.atproto.identity.requestPlcOperationSignature(undefined, { + headers: sc.getHeaders(alice), + }), + ) + + expect(mail.to).toEqual(sc.accounts[alice].email) + expect(mail.html).toContain('PLC Update Requested') + + const gotToken = getTokenFromMail(mail) + assert(gotToken) + token = gotToken + }) + + it('does not sign a plc operation with a bad token', async () => { + const attempt = agent.api.com.atproto.identity.signPlcOperation( + { + token: '123456', + rotationKeys: [sampleKey], + }, + { encoding: 'application/json', headers: sc.getHeaders(alice) }, + ) + await expect(attempt).rejects.toThrow('Token is invalid') + }) + + let operation: any + + it('signs a plc operation with a valid token', async () => { + const res = await agent.api.com.atproto.identity.signPlcOperation( + { + token, + rotationKeys: [sampleKey, ctx.plcRotationKey.did()], + }, + { encoding: 'application/json', headers: sc.getHeaders(alice) }, + ) + const currData = await ctx.plcClient.getDocumentData(alice) + expect(res.data.operation['alsoKnownAs']).toEqual(currData.alsoKnownAs) + expect(res.data.operation['verificationMethods']).toEqual( + currData.verificationMethods, + ) + expect(res.data.operation['services']).toEqual(currData.services) + expect(res.data.operation['rotationKeys']).toEqual([ + sampleKey, + ctx.plcRotationKey.did(), + ]) + operation = res.data.operation + }) + + it('submits a valid operation', async () => { + await agent.com.atproto.identity.submitPlcOperation( + { operation }, + { + encoding: 'application/json', + headers: sc.getHeaders(alice), + }, + ) + const didData = await ctx.plcClient.getDocumentData(alice) + expect(didData.rotationKeys).toEqual([sampleKey, ctx.plcRotationKey.did()]) + }) +}) diff --git a/packages/pds/tests/transfer-repo.test.ts b/packages/pds/tests/transfer-repo.test.ts deleted file mode 100644 index 48309b821ae..00000000000 --- a/packages/pds/tests/transfer-repo.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import path from 'node:path' -import os from 'node:os' -import axios from 'axios' -import * as ui8 from 'uint8arrays' -import { SeedClient, TestPds, TestPlc, mockResolvers } from '@atproto/dev-env' -import AtpAgent from '@atproto/api' -import * as pdsEntryway from '@atproto/pds-entryway' -import { Secp256k1Keypair, randomStr } from '@atproto/crypto' -import * as plcLib from '@did-plc/lib' -import getPort from 'get-port' - -describe('transfer repo', () => { - let plc: TestPlc - let pds: TestPds - let entryway: pdsEntryway.PDS - let entrywaySc: SeedClient - let pdsAgent: AtpAgent - let entrywayAgent: AtpAgent - - let did: string - const accountDetail = { - email: 'alice@test.com', - handle: 'alice.test', - password: 'test123', - } - - beforeAll(async () => { - const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) - const plcRotationKey = await Secp256k1Keypair.create({ exportable: true }) - const entrywayPort = await getPort() - plc = await TestPlc.create({}) - pds = await TestPds.create({ - entrywayUrl: `http://localhost:${entrywayPort}`, - entrywayDid: 'did:example:entryway', - entrywayJwtVerifyKeyK256PublicKeyHex: getPublicHex(jwtSigningKey), - entrywayPlcRotationKey: plcRotationKey.did(), - adminPassword: 'admin-pass', - serviceHandleDomains: [], - didPlcUrl: plc.url, - serviceDid: 'did:example:pds', - inviteRequired: false, - }) - entryway = await createEntryway({ - dbPostgresSchema: 'transfer_repo', - port: entrywayPort, - adminPassword: 'admin-pass', - jwtSigningKeyK256PrivateKeyHex: await getPrivateHex(jwtSigningKey), - plcRotationKeyK256PrivateKeyHex: await getPrivateHex(plcRotationKey), - inviteRequired: false, - serviceDid: 'did:example:entryway', - didPlcUrl: plc.url, - }) - mockResolvers(pds.ctx.idResolver, pds) - mockResolvers(entryway.ctx.idResolver, pds) - await entryway.ctx.db.db - .insertInto('pds') - .values({ - did: pds.ctx.cfg.service.did, - host: new URL(pds.ctx.cfg.service.publicUrl).host, - weight: 0, - }) - .execute() - pdsAgent = pds.getClient() - entrywayAgent = new AtpAgent({ - service: entryway.ctx.cfg.service.publicUrl, - }) - - // @ts-ignore network not needed - entrywaySc = new SeedClient({}, entrywayAgent) - await entrywaySc.createAccount('alice', accountDetail) - did = entrywaySc.dids.alice - for (let i = 0; i < 50; i++) { - const post = await entrywaySc.post(did, 'blah') - await entrywaySc.like(did, post.ref) - } - const img = await entrywaySc.uploadFile( - did, - '../dev-env/src/seed/img/key-landscape-small.jpg', - 'image/jpeg', - ) - await entrywaySc.post(did, 'img post', undefined, [img]) - }) - - afterAll(async () => { - await plc.close() - await entryway.destroy() - await pds.close() - }) - - it('transfers', async () => { - const signingKeyRes = - await pdsAgent.api.com.atproto.server.reserveSigningKey({ did }) - const signingKey = signingKeyRes.data.signingKey - - const repo = await entrywayAgent.api.com.atproto.sync.getRepo({ did }) - const importRes = await axios.post( - `${pds.url}/xrpc/com.atproto.temp.importRepo`, - repo.data, - { - params: { did }, - headers: { - 'content-type': 'application/vnd.ipld.car', - ...pds.adminAuthHeaders('admin'), - }, - decompress: true, - responseType: 'stream', - }, - ) - - for await (const _log of importRes.data) { - // noop just wait till import is finished - } - - const lastOp = await pds.ctx.plcClient.getLastOp(did) - if (!lastOp || lastOp.type === 'plc_tombstone') { - throw new Error('could not find last plc op') - } - const plcOp = await plcLib.createUpdateOp( - lastOp, - entryway.ctx.plcRotationKey, - (normalized) => ({ - ...normalized, - rotationKeys: [pds.ctx.plcRotationKey.did()], - verificationMethods: { - atproto: signingKey, - }, - services: { - atproto_pds: { - type: 'AtprotoPersonalDataServer', - endpoint: pds.ctx.cfg.service.publicUrl, - }, - }, - }), - ) - await pdsAgent.api.com.atproto.temp.transferAccount( - { - did, - handle: accountDetail.handle, - plcOp, - }, - { headers: pds.adminAuthHeaders('admin'), encoding: 'application/json' }, - ) - - await entryway.ctx.db.db - .updateTable('user_account') - .set({ - pdsId: entryway.ctx.db.db.selectFrom('pds').select('id').limit(1), - }) - .where('did', '=', did) - .execute() - - await pdsAgent.login({ - identifier: accountDetail.handle, - password: accountDetail.password, - }) - - await pdsAgent.api.app.bsky.feed.post.create( - { repo: did }, - { - text: 'asdflsidkfu', - createdAt: new Date().toISOString(), - }, - ) - - const listPosts = await pdsAgent.api.com.atproto.repo.listRecords({ - repo: did, - collection: 'app.bsky.feed.post', - limit: 100, - }) - const listLikes = await pdsAgent.api.com.atproto.repo.listRecords({ - repo: did, - collection: 'app.bsky.feed.like', - limit: 100, - }) - - expect(listPosts.data.records.length).toBe(52) - expect(listLikes.data.records.length).toBe(50) - }) -}) - -const createEntryway = async ( - config: pdsEntryway.ServerEnvironment & { - adminPassword: string - jwtSigningKeyK256PrivateKeyHex: string - plcRotationKeyK256PrivateKeyHex: string - }, -) => { - const signingKey = await Secp256k1Keypair.create({ exportable: true }) - const recoveryKey = await Secp256k1Keypair.create({ exportable: true }) - const env: pdsEntryway.ServerEnvironment = { - isEntryway: true, - recoveryDidKey: recoveryKey.did(), - serviceHandleDomains: ['.test'], - dbPostgresUrl: process.env.DB_POSTGRES_URL, - blobstoreDiskLocation: path.join(os.tmpdir(), randomStr(8, 'base32')), - bskyAppViewUrl: 'https://appview.invalid', - bskyAppViewDid: 'did:example:invalid', - bskyAppViewCdnUrlPattern: 'http://cdn.appview.com/%s/%s/%s', - jwtSecret: randomStr(8, 'base32'), - repoSigningKeyK256PrivateKeyHex: await getPrivateHex(signingKey), - modServiceUrl: 'https://mod.invalid', - modServiceDid: 'did:example:invalid', - ...config, - } - const cfg = pdsEntryway.envToCfg(env) - const secrets = pdsEntryway.envToSecrets(env) - const server = await pdsEntryway.PDS.create(cfg, secrets) - await server.ctx.db.migrateToLatestOrThrow() - await server.start() - // @TODO temp hack because entryway teardown calls signupActivator.run() by mistake - server.ctx.signupActivator.run = server.ctx.signupActivator.destroy - return server -} - -const getPublicHex = (key: Secp256k1Keypair) => { - return key.publicKeyStr('hex') -} - -const getPrivateHex = async (key: Secp256k1Keypair) => { - return ui8.toString(await key.export(), 'hex') -}