From 975e166a6271c96866aedfadc828274b9ea8496c Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 5 Oct 2023 11:52:49 -0400 Subject: [PATCH 001/135] scratch work on multi-pds auth --- packages/dev-env/src/pds.ts | 3 ++ packages/pds/package.json | 2 ++ .../api/com/atproto/server/createAccount.ts | 2 +- .../api/com/atproto/server/createSession.ts | 4 +-- .../api/com/atproto/server/refreshSession.ts | 2 +- packages/pds/src/auth.ts | 32 +++++++++++++++++-- packages/pds/src/config/env.ts | 4 +++ packages/pds/src/config/secrets.ts | 10 ++++++ packages/pds/src/context.ts | 7 ++++ packages/pds/src/db/database-schema.ts | 2 ++ .../20231004T040354739Z-user-account-pds.ts | 25 +++++++++++++++ packages/pds/src/db/migrations/index.ts | 1 + packages/pds/src/db/tables/pds.ts | 11 +++++++ packages/pds/src/db/tables/user-account.ts | 1 + pnpm-lock.yaml | 10 ++++++ 15 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts create mode 100644 packages/pds/src/db/tables/pds.ts diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index 501ae390cdb..eb11e35c6c2 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -20,6 +20,8 @@ export class TestPds { ) {} static async create(config: PdsConfig): Promise { + const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) + const jwtSigningPriv = ui8.toString(await jwtSigningKey.export(), 'hex') const repoSigningKey = await Secp256k1Keypair.create({ exportable: true }) const repoSigningPriv = ui8.toString(await repoSigningKey.export(), 'hex') const plcRotationKey = await Secp256k1Keypair.create({ exportable: true }) @@ -39,6 +41,7 @@ export class TestPds { moderatorPassword: MOD_PASSWORD, triagePassword: TRIAGE_PASSWORD, jwtSecret: 'jwt-secret', + jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, serviceHandleDomains: ['.test'], sequencerLeaderLockId: uniqueLockId(), bskyAppViewUrl: 'https://appview.invalid', diff --git a/packages/pds/package.json b/packages/pds/package.json index 4c22a61133b..4476ec5bc04 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -56,7 +56,9 @@ "http-errors": "^2.0.0", "http-terminator": "^3.2.0", "ioredis": "^5.3.2", + "jose": "^4.15.2", "jsonwebtoken": "^8.5.1", + "key-encoder": "^2.0.3", "kysely": "^0.22.0", "multiformats": "^9.9.0", "nodemailer": "^6.8.0", diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 334c2f2b132..12bbbfe6961 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -104,7 +104,7 @@ export default function (server: Server, ctx: AppContext) { .execute() } - const access = ctx.auth.createAccessToken({ did }) + const access = await ctx.auth.createAccessToken({ did }) const refresh = ctx.auth.createRefreshToken({ did }) await ctx.services.auth(dbTxn).grantRefreshToken(refresh.payload, null) diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 6d8d57e471e..2e7792c03b4 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -1,9 +1,9 @@ +import { DAY, MINUTE } from '@atproto/common' import { AuthRequiredError } from '@atproto/xrpc-server' import AppContext from '../../../../context' import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' import { AuthScope } from '../../../../auth' -import { DAY, MINUTE } from '@atproto/common' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createSession({ @@ -55,7 +55,7 @@ export default function (server: Server, ctx: AppContext) { ) } - const access = ctx.auth.createAccessToken({ + const access = await ctx.auth.createAccessToken({ did: user.did, scope: appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, }) diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 0fda8ba48a7..5b9de201b47 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -45,7 +45,7 @@ export default function (server: Server, ctx: AppContext) { throw new InvalidRequestError('Token has been revoked', 'ExpiredToken') } - const access = ctx.auth.createAccessToken({ + const access = await ctx.auth.createAccessToken({ did: user.did, scope: res.appPassName === null ? AuthScope.Access : AuthScope.AppPass, }) diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 6d75f1fd920..8676862576c 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -3,14 +3,20 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import * as ui8 from 'uint8arrays' import express from 'express' import * as jwt from 'jsonwebtoken' +import * as jose from 'jose' import AppContext from './context' import { softDeleted } from './db/util' +import { KeyObject } from 'crypto' +import * as c from 'crypto' +import KeyEncoder from 'key-encoder' +const keyEncoder = new KeyEncoder('secp256k1') const BEARER = 'Bearer ' const BASIC = 'Basic ' export type ServerAuthOpts = { jwtSecret: string + jwtSigningKey?: crypto.Secp256k1Keypair adminPass: string moderatorPass?: string triagePass?: string @@ -33,18 +39,20 @@ export type RefreshToken = AuthToken & { jti: string } export class ServerAuth { private _secret: string + private _signingKey?: crypto.Secp256k1Keypair private _adminPass: string private _moderatorPass?: string private _triagePass?: string constructor(opts: ServerAuthOpts) { this._secret = opts.jwtSecret + this._signingKey = opts.jwtSigningKey this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass this._triagePass = opts.triagePass } - createAccessToken(opts: { + async createAccessToken(opts: { did: string scope?: AuthScope expiresIn?: string | number @@ -54,13 +62,33 @@ export class ServerAuth { scope, sub: did, } - return { + // jose.decodeJwt + const x = new jose.SignJWT({ scope }) + .setProtectedHeader({ alg: 'ES256K' }) + .setExpirationTime(expiresIn) + .setSubject(did) + if (this._signingKey) { + const key = c.createPrivateKey({ + format: 'pem', + key: keyEncoder.encodePrivate( + Buffer.from(await this._signingKey.export()), + 'raw', + 'pem', + ), + }) + const signed = await x.sign(key) + console.log(signed) + } + // console.log(await x.sign()) + const y = { payload: payload as AuthToken, // exp set by sign() jwt: jwt.sign(payload, this._secret, { expiresIn: expiresIn, mutatePayload: true, }), } + console.log(y) + return y } createRefreshToken(opts: { diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 170e26d5976..1e3d84194b4 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -74,6 +74,9 @@ export const readEnv = (): ServerEnvironment => { // secrets jwtSecret: envStr('PDS_JWT_SECRET'), + jwtSigningKeyK256PrivateKeyHex: envStr( + 'PDS_JWT_SIGNING_KEY_K256_PRIVATE_KEY_HEX', + ), adminPassword: envStr('PDS_ADMIN_PASSWORD'), moderatorPassword: envStr('PDS_MODERATOR_PASSWORD'), triagePassword: envStr('PDS_TRIAGE_PASSWORD'), @@ -163,6 +166,7 @@ export type ServerEnvironment = { // secrets jwtSecret?: string + jwtSigningKeyK256PrivateKeyHex?: string adminPassword?: string moderatorPassword?: string triagePassword?: string diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index f0f876f1ccc..12acf2c132b 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -35,6 +35,14 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { throw new Error('Must configure plc rotation key') } + let jwtSigningKey: ServerSecrets['jwtSigningKey'] + if (env.jwtSigningKeyK256PrivateKeyHex) { + jwtSigningKey = { + provider: 'memory', + privateKeyHex: env.jwtSigningKeyK256PrivateKeyHex, + } + } + if (!env.jwtSecret) { throw new Error('Must provide a JWT secret') } @@ -45,6 +53,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { return { jwtSecret: env.jwtSecret, + jwtSigningKey, adminPassword: env.adminPassword, moderatorPassword: env.moderatorPassword ?? env.adminPassword, triagePassword: @@ -56,6 +65,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { export type ServerSecrets = { jwtSecret: string + jwtSigningKey?: SigningKeyMemory adminPassword: string moderatorPassword: string triagePassword: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 328b61893a1..68d21b053cd 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -152,7 +152,14 @@ export class AppContext { const appViewAgent = new AtpAgent({ service: cfg.bskyAppView.url }) + const jwtSigningKey = + secrets.jwtSigningKey && + (await crypto.Secp256k1Keypair.import( + secrets.jwtSigningKey.privateKeyHex, + )) + const auth = new ServerAuth({ + jwtSigningKey, jwtSecret: secrets.jwtSecret, adminPass: secrets.adminPassword, moderatorPass: secrets.moderatorPassword, diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 26159418206..171c24d455d 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely' import * as userAccount from './tables/user-account' +import * as pds from './tables/pds' import * as userPref from './tables/user-pref' import * as didHandle from './tables/did-handle' import * as repoRoot from './tables/repo-root' @@ -21,6 +22,7 @@ import * as runtimeFlag from './tables/runtime-flag' export type DatabaseSchemaType = appMigration.PartialDB & runtimeFlag.PartialDB & userAccount.PartialDB & + pds.PartialDB & userPref.PartialDB & didHandle.PartialDB & refreshToken.PartialDB & diff --git a/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts new file mode 100644 index 00000000000..c293b0694c9 --- /dev/null +++ b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts @@ -0,0 +1,25 @@ +import { Kysely } from 'kysely' +import { Dialect } from '..' + +export async function up(db: Kysely, dialect: Dialect): Promise { + const pdsBuilder = + dialect === 'pg' + ? db.schema + .createTable('pds') + .addColumn('id', 'serial', (col) => col.primaryKey()) + : db.schema + .createTable('pds') + .addColumn('id', 'integer', (col) => col.autoIncrement().primaryKey()) + await pdsBuilder + .addColumn('did', 'varchar', (col) => col.notNull()) + .addColumn('host', 'varchar', (col) => col.notNull()) + .execute() + await db.schema + .alterTable('user_account') + .addColumn('pdsId', 'integer', (col) => col.references('pds.id')) + .execute() +} +export async function down(db: Kysely): Promise { + await db.schema.alterTable('user_account').dropColumn('pdsId').execute() + await db.schema.dropTable('pds').execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index 9aead0d7012..05a22d816ca 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -6,3 +6,4 @@ export * as _20230613T164932261Z from './20230613T164932261Z-init' export * as _20230914T014727199Z from './20230914T014727199Z-repo-v3' export * as _20230926T195532354Z from './20230926T195532354Z-email-tokens' export * as _20230929T213219699Z from './20230929T213219699Z-takedown-id-as-int' +export * as _20231004T040354739Z from './20231004T040354739Z-user-account-pds' diff --git a/packages/pds/src/db/tables/pds.ts b/packages/pds/src/db/tables/pds.ts new file mode 100644 index 00000000000..b2f102366cc --- /dev/null +++ b/packages/pds/src/db/tables/pds.ts @@ -0,0 +1,11 @@ +import { GeneratedAlways } from 'kysely' + +export interface Pds { + id: GeneratedAlways + did: string + host: string +} + +export const tableName = 'pds' + +export type PartialDB = { [tableName]: Pds } diff --git a/packages/pds/src/db/tables/user-account.ts b/packages/pds/src/db/tables/user-account.ts index 808663ca468..3b45b089a42 100644 --- a/packages/pds/src/db/tables/user-account.ts +++ b/packages/pds/src/db/tables/user-account.ts @@ -8,6 +8,7 @@ export interface UserAccount { emailConfirmedAt: string | null invitesDisabled: Generated<0 | 1> inviteNote: string | null + pdsId: number | null } export type UserAccountEntry = Selectable diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e22af0c4e1..4edbd132db0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,9 +537,15 @@ importers: ioredis: specifier: ^5.3.2 version: 5.3.2 + jose: + specifier: ^4.15.2 + version: 4.15.2 jsonwebtoken: specifier: ^8.5.1 version: 8.5.1 + key-encoder: + specifier: ^2.0.3 + version: 2.0.3 kysely: specifier: ^0.22.0 version: 0.22.0 @@ -8729,6 +8735,10 @@ packages: - ts-node dev: true + /jose@4.15.2: + resolution: {integrity: sha512-IY73F228OXRl9ar3jJagh7Vnuhj/GzBunPiZP13K0lOl7Am9SoWW3kEzq3MCllJMTtZqHTiDXQvoRd4U95aU6A==} + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} From 3a59d673816a5cdfc271b8fc8a157cce9265c8a3 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 5 Oct 2023 23:53:48 -0400 Subject: [PATCH 002/135] move from jsonwebtoken to jose package, impl secp256k1 auth token --- packages/pds/package.json | 2 - .../api/com/atproto/server/createAccount.ts | 13 +- .../api/com/atproto/server/createSession.ts | 20 ++- .../api/com/atproto/server/deleteSession.ts | 9 +- .../api/com/atproto/server/refreshSession.ts | 17 +- packages/pds/src/auth.ts | 162 +++++++++--------- packages/pds/src/context.ts | 1 + packages/pds/src/logger.ts | 4 +- packages/pds/tests/app-passwords.test.ts | 14 +- packages/pds/tests/auth.test.ts | 23 +-- pnpm-lock.yaml | 81 --------- 11 files changed, 137 insertions(+), 209 deletions(-) diff --git a/packages/pds/package.json b/packages/pds/package.json index 4476ec5bc04..e4f94418296 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -57,7 +57,6 @@ "http-terminator": "^3.2.0", "ioredis": "^5.3.2", "jose": "^4.15.2", - "jsonwebtoken": "^8.5.1", "key-encoder": "^2.0.3", "kysely": "^0.22.0", "multiformats": "^9.9.0", @@ -82,7 +81,6 @@ "@types/disposable-email": "^0.2.0", "@types/express": "^4.17.13", "@types/express-serve-static-core": "^4.17.36", - "@types/jsonwebtoken": "^8.5.9", "@types/nodemailer": "^6.4.6", "@types/pg": "^8.6.6", "@types/qs": "^6.9.7", diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 12bbbfe6961..4cc26112735 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -104,17 +104,20 @@ export default function (server: Server, ctx: AppContext) { .execute() } - const access = await ctx.auth.createAccessToken({ did }) - const refresh = ctx.auth.createRefreshToken({ did }) - await ctx.services.auth(dbTxn).grantRefreshToken(refresh.payload, null) + const [accessJwt, refreshJwt] = await Promise.all([ + ctx.auth.createAccessToken({ did }), + ctx.auth.createRefreshToken({ did }), + ]) + const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) + await ctx.services.auth(dbTxn).grantRefreshToken(refreshPayload, null) // Setup repo root await repoTxn.createRepo(did, [], now) return { did, - accessJwt: access.jwt, - refreshJwt: refresh.jwt, + accessJwt, + refreshJwt, } }) diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 2e7792c03b4..56b6832f952 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -55,12 +55,16 @@ export default function (server: Server, ctx: AppContext) { ) } - const access = await ctx.auth.createAccessToken({ - did: user.did, - scope: appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, - }) - const refresh = ctx.auth.createRefreshToken({ did: user.did }) - await authService.grantRefreshToken(refresh.payload, appPasswordName) + const [accessJwt, refreshJwt] = await Promise.all([ + ctx.auth.createAccessToken({ + did: user.did, + scope: + appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, + }), + ctx.auth.createRefreshToken({ did: user.did }), + ]) + const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) + await authService.grantRefreshToken(refreshPayload, appPasswordName) return { encoding: 'application/json', @@ -69,8 +73,8 @@ export default function (server: Server, ctx: AppContext) { handle: user.handle, email: user.email, emailConfirmed: !!user.emailConfirmedAt, - accessJwt: access.jwt, - refreshJwt: refresh.jwt, + accessJwt, + refreshJwt, }, } }, diff --git a/packages/pds/src/api/com/atproto/server/deleteSession.ts b/packages/pds/src/api/com/atproto/server/deleteSession.ts index 28610df39d2..e62e70a84e5 100644 --- a/packages/pds/src/api/com/atproto/server/deleteSession.ts +++ b/packages/pds/src/api/com/atproto/server/deleteSession.ts @@ -9,9 +9,12 @@ export default function (server: Server, ctx: AppContext) { if (!token) { throw new AuthRequiredError() } - const refreshToken = ctx.auth.verifyToken(token, [AuthScope.Refresh], { - ignoreExpiration: true, - }) + const refreshToken = await ctx.auth.verifyToken( + token, + [AuthScope.Refresh], + { clockTolerance: Infinity }, // ignore expiration + ) + if (!refreshToken.jti) { throw new Error('Unexpected missing refresh token id') } diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 5b9de201b47..1e64faf06d5 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -22,10 +22,10 @@ export default function (server: Server, ctx: AppContext) { ) } - const lastRefreshId = ctx.auth.verifyToken( + const { jti: lastRefreshId } = await ctx.auth.verifyToken( ctx.auth.getToken(req) ?? '', [], - ).jti + ) if (!lastRefreshId) { throw new Error('Unexpected missing refresh token id') } @@ -34,18 +34,19 @@ export default function (server: Server, ctx: AppContext) { const authTxn = ctx.services.auth(dbTxn) const rotateRes = await authTxn.rotateRefreshToken(lastRefreshId) if (!rotateRes) return null - const refresh = ctx.auth.createRefreshToken({ + const refreshJwt = await ctx.auth.createRefreshToken({ did: user.did, jti: rotateRes.nextId, }) - await authTxn.grantRefreshToken(refresh.payload, rotateRes.appPassName) - return { refresh, appPassName: rotateRes.appPassName } + const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) + await authTxn.grantRefreshToken(refreshPayload, rotateRes.appPassName) + return { refreshJwt, appPassName: rotateRes.appPassName } }) if (res === null) { throw new InvalidRequestError('Token has been revoked', 'ExpiredToken') } - const access = await ctx.auth.createAccessToken({ + const accessJwt = await ctx.auth.createAccessToken({ did: user.did, scope: res.appPassName === null ? AuthScope.Access : AuthScope.AppPass, }) @@ -55,8 +56,8 @@ export default function (server: Server, ctx: AppContext) { body: { did: user.did, handle: user.handle, - accessJwt: access.jwt, - refreshJwt: res.refresh.jwt, + accessJwt: accessJwt, + refreshJwt: res.refreshJwt, }, } }, diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 8676862576c..98af4a295f9 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -1,18 +1,18 @@ -import * as crypto from '@atproto/crypto' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' -import * as ui8 from 'uint8arrays' +import * as assert from 'node:assert' +import { KeyObject, createPrivateKey, createSecretKey } from 'node:crypto' import express from 'express' -import * as jwt from 'jsonwebtoken' +import KeyEncoder from 'key-encoder' +import * as ui8 from 'uint8arrays' import * as jose from 'jose' +import * as crypto from '@atproto/crypto' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import AppContext from './context' import { softDeleted } from './db/util' -import { KeyObject } from 'crypto' -import * as c from 'crypto' -import KeyEncoder from 'key-encoder' -const keyEncoder = new KeyEncoder('secp256k1') const BEARER = 'Bearer ' const BASIC = 'Basic ' +const SECP256K1_JWT = 'ES256K' +const HMACSHA256_JWT = 'HS256' export type ServerAuthOpts = { jwtSecret: string @@ -38,15 +38,16 @@ export type AuthToken = { export type RefreshToken = AuthToken & { jti: string } export class ServerAuth { - private _secret: string - private _signingKey?: crypto.Secp256k1Keypair + private _signingSecret: KeyObject + private _signingKeyPromise?: Promise private _adminPass: string private _moderatorPass?: string private _triagePass?: string constructor(opts: ServerAuthOpts) { - this._secret = opts.jwtSecret - this._signingKey = opts.jwtSigningKey + this._signingSecret = createSecretKey(Buffer.from(opts.jwtSecret)) + this._signingKeyPromise = + opts.jwtSigningKey && createPrivateKeyObject(opts.jwtSigningKey) this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass this._triagePass = opts.triagePass @@ -58,89 +59,75 @@ export class ServerAuth { expiresIn?: string | number }) { const { did, scope = AuthScope.Access, expiresIn = '120mins' } = opts - const payload = { - scope, - sub: did, - } - // jose.decodeJwt - const x = new jose.SignJWT({ scope }) - .setProtectedHeader({ alg: 'ES256K' }) - .setExpirationTime(expiresIn) + + const signer = new jose.SignJWT({ scope }) .setSubject(did) - if (this._signingKey) { - const key = c.createPrivateKey({ - format: 'pem', - key: keyEncoder.encodePrivate( - Buffer.from(await this._signingKey.export()), - 'raw', - 'pem', - ), - }) - const signed = await x.sign(key) - console.log(signed) - } - // console.log(await x.sign()) - const y = { - payload: payload as AuthToken, // exp set by sign() - jwt: jwt.sign(payload, this._secret, { - expiresIn: expiresIn, - mutatePayload: true, - }), + .setIssuedAt() + .setExpirationTime(expiresIn) + + if (this._signingKeyPromise) { + const key = await this._signingKeyPromise + return signer.setProtectedHeader({ alg: SECP256K1_JWT }).sign(key) + } else { + const key = this._signingSecret + return signer.setProtectedHeader({ alg: HMACSHA256_JWT }).sign(key) } - console.log(y) - return y } - createRefreshToken(opts: { + async createRefreshToken(opts: { did: string jti?: string expiresIn?: string | number }) { const { did, jti = getRefreshTokenId(), expiresIn = '90days' } = opts - const payload = { - scope: AuthScope.Refresh, - sub: did, - jti, - } - return { - payload: payload as RefreshToken, // exp set by sign() - jwt: jwt.sign(payload, this._secret, { - expiresIn: expiresIn, - mutatePayload: true, - }), + + const signer = new jose.SignJWT({ scope: AuthScope.Refresh }) + .setSubject(did) + .setJti(jti) + .setIssuedAt() + .setExpirationTime(expiresIn) + + if (this._signingKeyPromise) { + const key = await this._signingKeyPromise + return signer.setProtectedHeader({ alg: SECP256K1_JWT }).sign(key) + } else { + const key = this._signingSecret + return signer.setProtectedHeader({ alg: HMACSHA256_JWT }).sign(key) } } - getCredentials( + // @NOTE unsafe for verification, should only be used w/ direct output from createRefreshToken() + decodeRefreshToken(jwt: string) { + const token = jose.decodeJwt(jwt) + assert.ok(token.scope === AuthScope.Refresh, 'not a refresh token') + return token as RefreshToken + } + + async getCredentials( req: express.Request, scopes = [AuthScope.Access], - ): { did: string; scope: AuthScope } | null { + ): Promise<{ did: string; scope: AuthScope } | null> { const token = this.getToken(req) if (!token) return null - const payload = this.verifyToken(token, scopes) + const payload = await this.verifyToken(token, scopes) const sub = payload.sub if (typeof sub !== 'string' || !sub.startsWith('did:')) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } - return { did: sub, scope: payload.scope } + return { did: sub, scope: payload.scope as AuthScope } } - getCredentialsOrThrow( + async getCredentialsOrThrow( req: express.Request, scopes: AuthScope[], - ): { did: string; scope: AuthScope } { - const creds = this.getCredentials(req, scopes) + ): Promise<{ did: string; scope: AuthScope }> { + const creds = await this.getCredentials(req, scopes) if (creds === null) { throw new AuthRequiredError(undefined, 'AuthMissing') } return creds } - verifyUser(req: express.Request, did: string, scopes: AuthScope[]): boolean { - const authorized = this.getCredentials(req, scopes) - return authorized !== null && authorized.did === did - } - verifyRole(req: express.Request) { const parsed = parseBasicAuth(req.headers.authorization || '') const { Missing, Valid, Invalid } = AuthStatus @@ -166,22 +153,23 @@ export class ServerAuth { return header.slice(BEARER.length) } - verifyToken( + async verifyToken( token: string, scopes: AuthScope[], - options?: jwt.VerifyOptions, - ): jwt.JwtPayload { + options?: jose.JWTVerifyOptions, + ): Promise { + const header = jose.decodeProtectedHeader(token) + let result: jose.JWTVerifyResult try { - const payload = jwt.verify(token, this._secret, options) - if (typeof payload === 'string' || 'signature' in payload) { - throw new InvalidRequestError('Malformed token', 'InvalidToken') - } - if (scopes.length > 0 && !scopes.includes(payload.scope)) { - throw new InvalidRequestError('Bad token scope', 'InvalidToken') + if (header.alg === SECP256K1_JWT && this._signingKeyPromise) { + const key = await this._signingKeyPromise + result = await jose.jwtVerify(token, key, options) + } else { + const key = this._signingSecret + result = await jose.jwtVerify(token, key, options) } - return payload } catch (err) { - if (err instanceof jwt.TokenExpiredError) { + if (err?.['code'] === 'ERR_JWT_EXPIRED') { throw new InvalidRequestError('Token has expired', 'ExpiredToken') } throw new InvalidRequestError( @@ -189,6 +177,10 @@ export class ServerAuth { 'InvalidToken', ) } + if (scopes.length > 0 && !scopes.includes(result.payload.scope as any)) { + throw new InvalidRequestError('Bad token scope', 'InvalidToken') + } + return result.payload } toString(): string { @@ -215,7 +207,7 @@ export const parseBasicAuth = ( export const accessVerifier = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [ + const creds = await auth.getCredentialsOrThrow(ctx.req, [ AuthScope.Access, AuthScope.AppPass, ]) @@ -228,7 +220,7 @@ export const accessVerifier = export const accessVerifierNotAppPassword = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [AuthScope.Access]) + const creds = await auth.getCredentialsOrThrow(ctx.req, [AuthScope.Access]) return { credentials: creds, artifacts: auth.getToken(ctx.req), @@ -238,7 +230,7 @@ export const accessVerifierNotAppPassword = export const accessVerifierCheckTakedown = (auth: ServerAuth, { db, services }: AppContext) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [ + const creds = await auth.getCredentialsOrThrow(ctx.req, [ AuthScope.Access, AuthScope.AppPass, ]) @@ -326,7 +318,7 @@ export const isUserOrAdmin = ( export const refreshVerifier = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [AuthScope.Refresh]) + const creds = await auth.getCredentialsOrThrow(ctx.req, [AuthScope.Refresh]) return { credentials: creds, artifacts: auth.getToken(ctx.req), @@ -352,3 +344,13 @@ export enum AuthStatus { Invalid, Missing, } + +const createPrivateKeyObject = async ( + privateKey: crypto.Secp256k1Keypair, +): Promise => { + const raw = await privateKey.export() + const key = keyEncoder.encodePrivate(ui8.toString(raw, 'hex'), 'raw', 'pem') + return createPrivateKey({ format: 'pem', key }) +} + +const keyEncoder = new KeyEncoder('secp256k1') diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 68d21b053cd..fd3b30ff363 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -156,6 +156,7 @@ export class AppContext { secrets.jwtSigningKey && (await crypto.Secp256k1Keypair.import( secrets.jwtSigningKey.privateKeyHex, + { exportable: true }, )) const auth = new ServerAuth({ diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index a4277e41669..fe17a4e8c0a 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -1,7 +1,7 @@ import pino from 'pino' import pinoHttp from 'pino-http' import { subsystemLogger } from '@atproto/common' -import * as jwt from 'jsonwebtoken' +import * as jose from 'jose' import { parseBasicAuth } from './auth' export const dbLogger = subsystemLogger('pds:db') @@ -22,7 +22,7 @@ export const loggerMiddleware = pinoHttp({ let auth: string | undefined = undefined if (authHeader.startsWith('Bearer ')) { const token = authHeader.slice('Bearer '.length) - const sub = jwt.decode(token)?.sub + const { sub } = jose.decodeJwt(token) if (sub) { auth = 'Bearer ' + sub } else { diff --git a/packages/pds/tests/app-passwords.test.ts b/packages/pds/tests/app-passwords.test.ts index c8e1309dda8..dc657da9078 100644 --- a/packages/pds/tests/app-passwords.test.ts +++ b/packages/pds/tests/app-passwords.test.ts @@ -1,6 +1,6 @@ +import * as jose from 'jose' import { TestNetworkNoAppView } from '@atproto/dev-env' import AtpAgent from '@atproto/api' -import * as jwt from 'jsonwebtoken' describe('app_passwords', () => { let network: TestNetworkNoAppView @@ -44,9 +44,7 @@ describe('app_passwords', () => { }) it('creates an access token for an app with a restricted scope', () => { - const decoded = jwt.decode(appAgent.session?.accessJwt ?? '', { - json: true, - }) + const decoded = jose.decodeJwt(appAgent.session?.accessJwt ?? '') expect(decoded?.scope).toEqual('com.atproto.appPass') }) @@ -66,7 +64,7 @@ describe('app_passwords', () => { const attempt = appAgent.api.com.atproto.server.createAppPassword({ name: 'another-one', }) - await expect(attempt).rejects.toThrow('Token could not be verified') + await expect(attempt).rejects.toThrow('Bad token scope') }) it('persists scope across refreshes', async () => { @@ -93,15 +91,13 @@ describe('app_passwords', () => { ) const attempt = appAgent.api.com.atproto.server.createAppPassword( - { - name: 'another-one', - }, + { name: 'another-one' }, { encoding: 'application/json', headers: { authorization: `Bearer ${session.data.accessJwt}` }, }, ) - await expect(attempt).rejects.toThrow('Token could not be verified') + await expect(attempt).rejects.toThrow('Bad token scope') }) it('lists available app-specific passwords', async () => { diff --git a/packages/pds/tests/auth.test.ts b/packages/pds/tests/auth.test.ts index d94eebf17e1..9046562eafc 100644 --- a/packages/pds/tests/auth.test.ts +++ b/packages/pds/tests/auth.test.ts @@ -1,4 +1,4 @@ -import * as jwt from 'jsonwebtoken' +import * as jose from 'jose' import AtpAgent from '@atproto/api' import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' @@ -159,9 +159,9 @@ describe('auth', () => { const refresh1 = await refreshSession(account.refreshJwt) const refresh2 = await refreshSession(account.refreshJwt) - const token0 = jwt.decode(account.refreshJwt, { json: true }) - const token1 = jwt.decode(refresh1.refreshJwt, { json: true }) - const token2 = jwt.decode(refresh2.refreshJwt, { json: true }) + const token0 = jose.decodeJwt(account.refreshJwt) + const token1 = jose.decodeJwt(refresh1.refreshJwt) + const token2 = jose.decodeJwt(refresh2.refreshJwt) expect(typeof token1?.jti).toEqual('string') expect(token1?.jti).toEqual(token2?.jti) @@ -177,7 +177,7 @@ describe('auth', () => { password: 'password', }) await refreshSession(account.refreshJwt) - const token = jwt.decode(account.refreshJwt, { json: true }) + const token = jose.decodeJwt(account.refreshJwt) // Update expiration (i.e. grace period) to end immediately const refreshUpdated = await db.db @@ -219,9 +219,7 @@ describe('auth', () => { password: 'password', }) const refreshWithAccess = refreshSession(account.accessJwt) - await expect(refreshWithAccess).rejects.toThrow( - 'Token could not be verified', - ) + await expect(refreshWithAccess).rejects.toThrow('Bad token scope') }) it('expired refresh token cannot be used to refresh a session.', async () => { @@ -231,10 +229,13 @@ describe('auth', () => { email: 'holga@test.com', password: 'password', }) - const refresh = auth.createRefreshToken({ did: account.did, expiresIn: -1 }) - const refreshExpired = refreshSession(refresh.jwt) + const refreshJwt = await auth.createRefreshToken({ + did: account.did, + expiresIn: -1, + }) + const refreshExpired = refreshSession(refreshJwt) await expect(refreshExpired).rejects.toThrow('Token has expired') - await deleteSession(refresh.jwt) // No problem revoking an expired token + await deleteSession(refreshJwt) // No problem revoking an expired token }) it('actor takedown disallows fresh session.', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4edbd132db0..13e6c6062b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,9 +540,6 @@ importers: jose: specifier: ^4.15.2 version: 4.15.2 - jsonwebtoken: - specifier: ^8.5.1 - version: 8.5.1 key-encoder: specifier: ^2.0.3 version: 2.0.3 @@ -607,9 +604,6 @@ importers: '@types/express-serve-static-core': specifier: ^4.17.36 version: 4.17.36 - '@types/jsonwebtoken': - specifier: ^8.5.9 - version: 8.5.9 '@types/nodemailer': specifier: ^6.4.6 version: 6.4.6 @@ -5384,12 +5378,6 @@ packages: resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} dev: true - /@types/jsonwebtoken@8.5.9: - resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} - dependencies: - '@types/node': 18.17.8 - dev: true - /@types/mime@1.3.2: resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==} dev: true @@ -6104,10 +6092,6 @@ packages: node-int64: 0.4.0 dev: true - /buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - dev: false - /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -6716,12 +6700,6 @@ packages: engines: {node: '>=10'} dev: true - /ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - dependencies: - safe-buffer: 5.2.1 - dev: false - /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -8822,37 +8800,6 @@ packages: graceful-fs: 4.2.11 dev: true - /jsonwebtoken@8.5.1: - resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} - engines: {node: '>=4', npm: '>=1.4.28'} - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 5.7.2 - dev: false - - /jwa@1.4.1: - resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - dev: false - - /jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - dependencies: - jwa: 1.4.1 - safe-buffer: 5.2.1 - dev: false - /key-encoder@2.0.3: resolution: {integrity: sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg==} dependencies: @@ -8944,34 +8891,10 @@ packages: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} dev: false - /lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - dev: false - /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} dev: false - /lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - dev: false - - /lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - dev: false - - /lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - dev: false - - /lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - dev: false - - /lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - dev: false - /lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} dev: false @@ -8980,10 +8903,6 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true - /lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - dev: false - /lodash.pick@4.4.0: resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==} dev: false From 73e137cb02e5f2b0af4db1b1143d2b8f08dffc26 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 6 Oct 2023 00:48:31 -0400 Subject: [PATCH 003/135] pds assignment, pds in tokens --- .../api/com/atproto/server/createAccount.ts | 31 +++++++++++++++---- .../api/com/atproto/server/createSession.ts | 6 +++- .../api/com/atproto/server/refreshSession.ts | 2 ++ packages/pds/src/auth.ts | 9 +++++- packages/pds/src/services/account/index.ts | 16 ++++++++-- 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 4cc26112735..11a012e4bb5 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,7 +1,10 @@ +import { randomInt } from 'node:crypto' import { InvalidRequestError } from '@atproto/xrpc-server' import disposable from 'disposable-email' -import { normalizeAndValidateHandle } from '../../../../handle' import * as plc from '@did-plc/lib' +import { MINUTE } from '@atproto/common' +import { AtprotoData } from '@atproto/identity' +import { normalizeAndValidateHandle } from '../../../../handle' import * as scrypt from '../../../../db/scrypt' import { Server } from '../../../../lexicon' import { InputSchema as CreateAccountInput } from '../../../../lexicon/types/com/atproto/server/createAccount' @@ -9,8 +12,6 @@ import { countAll } from '../../../../db/util' import { UserAlreadyExistsError } from '../../../../services/account' import AppContext from '../../../../context' import Database from '../../../../db' -import { AtprotoData } from '@atproto/identity' -import { MINUTE } from '@atproto/common' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -52,6 +53,7 @@ export default function (server: Server, ctx: AppContext) { const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) + const pds = await assignPds(ctx) const result = await ctx.db.transaction(async (dbTxn) => { const actorTxn = ctx.services.account(dbTxn) @@ -66,7 +68,13 @@ export default function (server: Server, ctx: AppContext) { // Register user before going out to PLC to get a real did try { - await actorTxn.registerUser({ email, handle, did, passwordScrypt }) + await actorTxn.registerUser({ + email, + handle, + did, + pdsId: pds?.id, + passwordScrypt, + }) } catch (err) { if (err instanceof UserAlreadyExistsError) { const got = await actorTxn.getAccount(handle, true) @@ -105,13 +113,17 @@ export default function (server: Server, ctx: AppContext) { } const [accessJwt, refreshJwt] = await Promise.all([ - ctx.auth.createAccessToken({ did }), - ctx.auth.createRefreshToken({ did }), + ctx.auth.createAccessToken({ did, pdsDid: pds?.did }), + ctx.auth.createRefreshToken({ + did, + identityDid: ctx.cfg.service.did, + }), ]) const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) await ctx.services.auth(dbTxn).grantRefreshToken(refreshPayload, null) // Setup repo root + // @TODO contact pds for repo setup await repoTxn.createRepo(did, [], now) return { @@ -247,3 +259,10 @@ const getDidAndPlcOp = async ( return { did: input.did, plcOp: null } } + +// @TODO this implementation is a stub +const assignPds = async (ctx: AppContext) => { + const pdses = await ctx.db.db.selectFrom('pds').selectAll().execute() + const pds = pdses.at(randomInt(pdses.length)) + return pds +} diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 56b6832f952..97cf4082ca1 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -58,10 +58,14 @@ export default function (server: Server, ctx: AppContext) { const [accessJwt, refreshJwt] = await Promise.all([ ctx.auth.createAccessToken({ did: user.did, + pdsDid: user.pdsDid, scope: appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, }), - ctx.auth.createRefreshToken({ did: user.did }), + ctx.auth.createRefreshToken({ + did: user.did, + identityDid: ctx.cfg.service.did, + }), ]) const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) await authService.grantRefreshToken(refreshPayload, appPasswordName) diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 1e64faf06d5..ebaed790d29 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -36,6 +36,7 @@ export default function (server: Server, ctx: AppContext) { if (!rotateRes) return null const refreshJwt = await ctx.auth.createRefreshToken({ did: user.did, + identityDid: ctx.cfg.service.did, jti: rotateRes.nextId, }) const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) @@ -48,6 +49,7 @@ export default function (server: Server, ctx: AppContext) { const accessJwt = await ctx.auth.createAccessToken({ did: user.did, + pdsDid: user.pdsDid, scope: res.appPassName === null ? AuthScope.Access : AuthScope.AppPass, }) diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 98af4a295f9..b9b3bae7503 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -33,9 +33,10 @@ export type AuthToken = { scope: AuthScope sub: string exp: number + aud?: string } -export type RefreshToken = AuthToken & { jti: string } +export type RefreshToken = AuthToken & { jti: string; aud: string } export class ServerAuth { private _signingSecret: KeyObject @@ -55,6 +56,7 @@ export class ServerAuth { async createAccessToken(opts: { did: string + pdsDid?: string | null scope?: AuthScope expiresIn?: string | number }) { @@ -64,6 +66,9 @@ export class ServerAuth { .setSubject(did) .setIssuedAt() .setExpirationTime(expiresIn) + if (opts.pdsDid) { + signer.setAudience(opts.pdsDid) + } if (this._signingKeyPromise) { const key = await this._signingKeyPromise @@ -76,6 +81,7 @@ export class ServerAuth { async createRefreshToken(opts: { did: string + identityDid: string jti?: string expiresIn?: string | number }) { @@ -83,6 +89,7 @@ export class ServerAuth { const signer = new jose.SignJWT({ scope: AuthScope.Refresh }) .setSubject(did) + .setAudience(opts.identityDid) .setJti(jti) .setIssuedAt() .setExpirationTime(expiresIn) diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 9a6910d0e4f..2d59afecb0a 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -25,12 +25,13 @@ export class AccountService { async getAccount( handleOrDid: string, includeSoftDeleted = false, - ): Promise<(UserAccountEntry & DidHandle & RepoRoot) | null> { + ): Promise { const { ref } = this.db.db.dynamic const result = await this.db.db .selectFrom('user_account') .innerJoin('did_handle', 'did_handle.did', 'user_account.did') .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .leftJoin('pds', 'pds.id', 'user_account.pdsId') .if(!includeSoftDeleted, (qb) => qb.where(notSoftDeletedClause(ref('repo_root'))), ) @@ -47,6 +48,7 @@ export class AccountService { ) } }) + .select(['pds.did as pdsDid']) .selectAll('user_account') .selectAll('did_handle') .selectAll('repo_root') @@ -68,16 +70,18 @@ export class AccountService { async getAccountByEmail( email: string, includeSoftDeleted = false, - ): Promise<(UserAccountEntry & DidHandle & RepoRoot) | null> { + ): Promise { const { ref } = this.db.db.dynamic const found = await this.db.db .selectFrom('user_account') .innerJoin('did_handle', 'did_handle.did', 'user_account.did') .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .leftJoin('pds', 'pds.id', 'user_account.pdsId') .if(!includeSoftDeleted, (qb) => qb.where(notSoftDeletedClause(ref('repo_root'))), ) .where('email', '=', email.toLowerCase()) + .select(['pds.did as pdsDid']) .selectAll('user_account') .selectAll('did_handle') .selectAll('repo_root') @@ -113,16 +117,18 @@ export class AccountService { email: string handle: string did: string + pdsId?: number passwordScrypt: string }) { this.db.assertTransaction() - const { email, handle, did, passwordScrypt } = opts + const { email, handle, did, pdsId, passwordScrypt } = opts log.debug({ handle, email }, 'registering user') const registerUserAccnt = this.db.db .insertInto('user_account') .values({ email: email.toLowerCase(), did, + pdsId, passwordScrypt, createdAt: new Date().toISOString(), }) @@ -623,3 +629,7 @@ const matchNamespace = (namespace: string, fullname: string) => { } export type HandleSequenceToken = { did: string; handle: string } + +type AccountInfo = UserAccountEntry & + DidHandle & + RepoRoot & { pdsDid: string | null } From 966572e10cf6c02109e0ec71ad2fbb50ee883063 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 6 Oct 2023 12:51:28 -0400 Subject: [PATCH 004/135] move authPassthru util --- .../pds/src/api/app/bsky/actor/getProfile.ts | 2 +- .../src/api/app/bsky/feed/getActorLikes.ts | 2 +- .../src/api/app/bsky/feed/getAuthorFeed.ts | 2 +- .../src/api/app/bsky/feed/getPostThread.ts | 2 +- .../src/api/app/bsky/graph/getFollowers.ts | 2 +- .../pds/src/api/app/bsky/graph/getFollows.ts | 2 +- .../com/atproto/admin/getModerationAction.ts | 3 +- .../com/atproto/admin/getModerationActions.ts | 2 +- .../com/atproto/admin/getModerationReport.ts | 3 +- .../com/atproto/admin/getModerationReports.ts | 2 +- .../src/api/com/atproto/admin/getRecord.ts | 3 +- .../pds/src/api/com/atproto/admin/getRepo.ts | 3 +- .../atproto/admin/resolveModerationReports.ts | 2 +- .../atproto/admin/reverseModerationAction.ts | 2 +- .../src/api/com/atproto/admin/searchRepos.ts | 2 +- .../com/atproto/admin/takeModerationAction.ts | 2 +- .../pds/src/api/com/atproto/admin/util.ts | 24 ------- packages/pds/src/api/proxy.ts | 70 +++++++++++++++++++ 18 files changed, 90 insertions(+), 40 deletions(-) create mode 100644 packages/pds/src/api/proxy.ts diff --git a/packages/pds/src/api/app/bsky/actor/getProfile.ts b/packages/pds/src/api/app/bsky/actor/getProfile.ts index c200e1dd75f..afb4df09fbb 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfile.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfile.ts @@ -1,9 +1,9 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../../api/com/atproto/admin/util' import { OutputSchema } from '../../../../lexicon/types/app/bsky/actor/getProfile' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getProfile({ diff --git a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts index 9c0c38c5a20..57696e382b9 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts @@ -2,8 +2,8 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' import { handleReadAfterWrite } from '../util/read-after-write' -import { authPassthru } from '../../../../api/com/atproto/admin/util' import { LocalRecords } from '../../../../services/local' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getActorLikes({ diff --git a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts index 6563812fb9a..f07200c0bd2 100644 --- a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts @@ -2,9 +2,9 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' import { handleReadAfterWrite } from '../util/read-after-write' -import { authPassthru } from '../../../../api/com/atproto/admin/util' import { LocalRecords } from '../../../../services/local' import { isReasonRepost } from '../../../../lexicon/types/app/bsky/feed/defs' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getAuthorFeed({ diff --git a/packages/pds/src/api/app/bsky/feed/getPostThread.ts b/packages/pds/src/api/app/bsky/feed/getPostThread.ts index 270c1044497..4189524c869 100644 --- a/packages/pds/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/pds/src/api/app/bsky/feed/getPostThread.ts @@ -22,7 +22,7 @@ import { getRepoRev, handleReadAfterWrite, } from '../util/read-after-write' -import { authPassthru } from '../../../com/atproto/admin/util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getPostThread({ diff --git a/packages/pds/src/api/app/bsky/graph/getFollowers.ts b/packages/pds/src/api/app/bsky/graph/getFollowers.ts index 389f92d4e14..f438e95251b 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollowers.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../../api/com/atproto/admin/util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollowers({ diff --git a/packages/pds/src/api/app/bsky/graph/getFollows.ts b/packages/pds/src/api/app/bsky/graph/getFollows.ts index 343fd81d414..91684f65e38 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollows.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollows.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../../api/com/atproto/admin/util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollows({ diff --git a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts index 258ca9d94a1..1848591737c 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts @@ -1,7 +1,8 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' import { isRepoView } from '../../../../lexicon/types/com/atproto/admin/defs' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationAction({ diff --git a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts index 0ef48e99851..301aecef541 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationActions({ diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts index b75268ebdf8..34f28b8bd18 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts @@ -1,7 +1,8 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' import { isRepoView } from '../../../../lexicon/types/com/atproto/admin/defs' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReport({ diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts index 2d5dd329bc4..2135cbaa6d9 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReports({ diff --git a/packages/pds/src/api/com/atproto/admin/getRecord.ts b/packages/pds/src/api/com/atproto/admin/getRecord.ts index b68d01aefda..0ebf1013c48 100644 --- a/packages/pds/src/api/com/atproto/admin/getRecord.ts +++ b/packages/pds/src/api/com/atproto/admin/getRecord.ts @@ -2,7 +2,8 @@ import { AtUri } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRecord({ diff --git a/packages/pds/src/api/com/atproto/admin/getRepo.ts b/packages/pds/src/api/com/atproto/admin/getRepo.ts index 19e07862851..fdca03c1759 100644 --- a/packages/pds/src/api/com/atproto/admin/getRepo.ts +++ b/packages/pds/src/api/com/atproto/admin/getRepo.ts @@ -1,7 +1,8 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ diff --git a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts index 52279745e46..c8fe76b7aad 100644 --- a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.resolveModerationReports({ diff --git a/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts b/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts index a8e8d62a3ad..1765b705212 100644 --- a/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts @@ -9,7 +9,7 @@ import { TAKEDOWN, } from '../../../../lexicon/types/com/atproto/admin/defs' import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.reverseModerationAction({ diff --git a/packages/pds/src/api/com/atproto/admin/searchRepos.ts b/packages/pds/src/api/com/atproto/admin/searchRepos.ts index da2d7fa3788..3c3fe0e0b5f 100644 --- a/packages/pds/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/api/com/atproto/admin/searchRepos.ts @@ -2,7 +2,7 @@ import { sql } from 'kysely' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { ListKeyset } from '../../../../services/account' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.searchRepos({ diff --git a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts index fb593b1c957..080c8cfb08d 100644 --- a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts @@ -11,7 +11,7 @@ import { import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' import { getSubject, getAction } from '../moderation/util' import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.takeModerationAction({ diff --git a/packages/pds/src/api/com/atproto/admin/util.ts b/packages/pds/src/api/com/atproto/admin/util.ts index f8bab4460a5..841d3b2b9f2 100644 --- a/packages/pds/src/api/com/atproto/admin/util.ts +++ b/packages/pds/src/api/com/atproto/admin/util.ts @@ -1,32 +1,8 @@ -import express from 'express' import { RepoView, RepoViewDetail, } from '../../../../lexicon/types/com/atproto/admin/defs' -// Output designed to passed as second arg to AtpAgent methods. -// The encoding field here is a quirk of the AtpAgent. -export function authPassthru( - req: express.Request, - withEncoding?: false, -): { headers: { authorization: string }; encoding: undefined } | undefined - -export function authPassthru( - req: express.Request, - withEncoding: true, -): - | { headers: { authorization: string }; encoding: 'application/json' } - | undefined - -export function authPassthru(req: express.Request, withEncoding?: boolean) { - if (req.headers.authorization) { - return { - headers: { authorization: req.headers.authorization }, - encoding: withEncoding ? 'application/json' : undefined, - } - } -} - // @NOTE mutates. // merges-in details that the pds knows about the repo. export function mergeRepoViewPdsDetails( diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts new file mode 100644 index 00000000000..f854004d7bd --- /dev/null +++ b/packages/pds/src/api/proxy.ts @@ -0,0 +1,70 @@ +import * as express from 'express' +import AtpAgent from '@atproto/api' +import { Headers, XRPCError } from '@atproto/xrpc' +import { InvalidRequestError, UpstreamFailureError } from '@atproto/xrpc-server' +import AppContext from '../context' + +export const proxy = async ( + ctx: AppContext, + creds: { audience?: string }, + fn: (agent: AtpAgent) => Promise, +): Promise => { + if (!creds.audience || creds.audience === ctx.cfg.service.did) { + return null + } + const accountService = ctx.services.account(ctx.db) + const pds = await accountService.getPds(creds.audience) + if (!pds) { + throw new UpstreamFailureError('unknown pds in credentials') + } + // @TODO reuse agents + const agent = new AtpAgent({ service: `https://${pds.host}` }) + try { + return await fn(agent) + } catch (err) { + // @TODO may need to pass through special lexicon errors + if ( + err instanceof XRPCError && + err.status === 403 && + err.error === 'AccountNotFound' + ) { + // instruct client to refresh token during potential account migration + throw new InvalidRequestError( + 'Token audience is out of date', + 'ExpiredToken', + ) + } + throw err + } +} + +export const resultPassthru = (result: { headers: Headers; data: T }) => { + // @TODO pass through any headers that we always want to forward along + return { + encoding: 'application/json' as const, + body: result.data, + } +} + +// Output designed to passed as second arg to AtpAgent methods. +// The encoding field here is a quirk of the AtpAgent. +export function authPassthru( + req: express.Request, + withEncoding?: false, +): { headers: { authorization: string }; encoding: undefined } | undefined + +export function authPassthru( + req: express.Request, + withEncoding: true, +): + | { headers: { authorization: string }; encoding: 'application/json' } + | undefined + +export function authPassthru(req: express.Request, withEncoding?: boolean) { + if (req.headers.authorization) { + return { + headers: { authorization: req.headers.authorization }, + encoding: withEncoding ? 'application/json' : undefined, + } + } +} From c46320038d6ad99dac3ea5ddbacda2a5b081dab1 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 6 Oct 2023 12:54:55 -0400 Subject: [PATCH 005/135] setup proxying repo write ops --- .../src/api/com/atproto/repo/applyWrites.ts | 13 ++++++++++++- .../src/api/com/atproto/repo/createRecord.ts | 14 +++++++++++++- .../src/api/com/atproto/repo/deleteRecord.ts | 13 ++++++++++++- .../pds/src/api/com/atproto/repo/putRecord.ts | 14 +++++++++++++- .../pds/src/api/com/atproto/repo/uploadBlob.ts | 15 ++++++++++++++- packages/pds/src/auth.ts | 18 ++++++++++++++---- packages/pds/src/services/account/index.ts | 9 +++++++++ 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/packages/pds/src/api/com/atproto/repo/applyWrites.ts b/packages/pds/src/api/com/atproto/repo/applyWrites.ts index d5be8bb720d..c6537caec65 100644 --- a/packages/pds/src/api/com/atproto/repo/applyWrites.ts +++ b/packages/pds/src/api/com/atproto/repo/applyWrites.ts @@ -15,6 +15,7 @@ import { } from '../../../../repo' import AppContext from '../../../../context' import { ConcurrentWriteError } from '../../../../services/repo' +import { proxy, authPassthru } from '../../../proxy' const ratelimitPoints = ({ input }: { input: HandlerInput }) => { let points = 0 @@ -46,7 +47,17 @@ export default function (server: Server, ctx: AppContext) { }, ], - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy(ctx, auth.credentials, async (agent) => { + await agent.api.com.atproto.repo.applyWrites( + input.body, + authPassthru(req, true), + ) + }) + if (proxied !== null) { + return proxied + } + const tx = input.body const { repo, validate, swapCommit } = tx const did = await ctx.services.account(ctx.db).getDidForActor(repo) diff --git a/packages/pds/src/api/com/atproto/repo/createRecord.ts b/packages/pds/src/api/com/atproto/repo/createRecord.ts index 26bc5614785..83dda1224c9 100644 --- a/packages/pds/src/api/com/atproto/repo/createRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/createRecord.ts @@ -12,6 +12,7 @@ import AppContext from '../../../../context' import { ids } from '../../../../lexicon/lexicons' import Database from '../../../../db' import { ConcurrentWriteError } from '../../../../services/repo' +import { proxy, resultPassthru, authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.createRecord({ @@ -28,7 +29,18 @@ export default function (server: Server, ctx: AppContext) { calcPoints: () => 3, }, ], - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy(ctx, auth.credentials, async (agent) => { + const result = await agent.api.com.atproto.repo.createRecord( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + const { repo, collection, rkey, record, swapCommit, validate } = input.body const did = await ctx.services.account(ctx.db).getDidForActor(repo) diff --git a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts index 99f171e0849..28213aeaa4c 100644 --- a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts @@ -5,6 +5,7 @@ import AppContext from '../../../../context' import { BadCommitSwapError, BadRecordSwapError } from '../../../../repo' import { CID } from 'multiformats/cid' import { ConcurrentWriteError } from '../../../../services/repo' +import { proxy, authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.deleteRecord({ @@ -21,7 +22,17 @@ export default function (server: Server, ctx: AppContext) { calcPoints: () => 1, }, ], - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy(ctx, auth.credentials, async (agent) => { + await agent.api.com.atproto.repo.deleteRecord( + input.body, + authPassthru(req, true), + ) + }) + if (proxied !== null) { + return proxied + } + const { repo, collection, rkey, swapCommit, swapRecord } = input.body const did = await ctx.services.account(ctx.db).getDidForActor(repo) diff --git a/packages/pds/src/api/com/atproto/repo/putRecord.ts b/packages/pds/src/api/com/atproto/repo/putRecord.ts index 8fdcc776bb9..4dfcfa23586 100644 --- a/packages/pds/src/api/com/atproto/repo/putRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/putRecord.ts @@ -12,6 +12,7 @@ import { PreparedUpdate, } from '../../../../repo' import { ConcurrentWriteError } from '../../../../services/repo' +import { proxy, resultPassthru, authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.putRecord({ @@ -28,7 +29,18 @@ export default function (server: Server, ctx: AppContext) { calcPoints: () => 2, }, ], - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { + const proxied = await proxy(ctx, auth.credentials, async (agent) => { + const result = await agent.api.com.atproto.repo.putRecord( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + const { repo, collection, diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index b5a6eaecaef..90aee09cad8 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -1,10 +1,23 @@ +import { streamToBytes } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.uploadBlob({ auth: ctx.accessVerifierCheckTakedown, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { + const proxied = await proxy(ctx, auth.credentials, async (agent) => { + const result = await agent.api.com.atproto.repo.uploadBlob( + await streamToBytes(input.body), // @TODO proxy streaming + authPassthru(req, true), + ) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const blob = await ctx.services .repo(ctx.db) diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index b9b3bae7503..ca9a884579a 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -113,21 +113,31 @@ export class ServerAuth { async getCredentials( req: express.Request, scopes = [AuthScope.Access], - ): Promise<{ did: string; scope: AuthScope } | null> { + ): Promise<{ did: string; scope: AuthScope; audience?: string } | null> { const token = this.getToken(req) if (!token) return null const payload = await this.verifyToken(token, scopes) - const sub = payload.sub + const { sub, aud, scope } = payload if (typeof sub !== 'string' || !sub.startsWith('did:')) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } - return { did: sub, scope: payload.scope as AuthScope } + if ( + aud !== undefined && + (typeof aud !== 'string' || !aud.startsWith('did:')) + ) { + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } + return { + did: sub, + audience: aud, + scope: scope as AuthScope, + } } async getCredentialsOrThrow( req: express.Request, scopes: AuthScope[], - ): Promise<{ did: string; scope: AuthScope }> { + ): Promise<{ did: string; scope: AuthScope; audience?: string }> { const creds = await this.getCredentials(req, scopes) if (creds === null) { throw new AuthRequiredError(undefined, 'AuthMissing') diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 2d59afecb0a..c381881b466 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -594,6 +594,15 @@ export class AccountService { await this.db.db.insertInto('user_pref').values(putPrefs).execute() } } + + // @TODO cache w/ in-mem lookup + async getPds(pdsDid: string) { + return await this.db.db + .selectFrom('pds') + .where('did', '=', pdsDid) + .selectAll() + .executeTakeFirst() + } } export type UserPreference = Record & { $type: string } From 36f359b17edb77d3c2254781017ca4f582626830 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 6 Oct 2023 16:28:20 -0400 Subject: [PATCH 006/135] authed and unauthed proxying on com.atproto.repo endpoints --- .../src/api/com/atproto/repo/applyWrites.ts | 11 +++--- .../src/api/com/atproto/repo/createRecord.ts | 16 ++++++--- .../src/api/com/atproto/repo/deleteRecord.ts | 11 +++--- .../src/api/com/atproto/repo/describeRepo.ts | 13 +++++-- .../pds/src/api/com/atproto/repo/getRecord.ts | 35 +++++++++---------- .../src/api/com/atproto/repo/listRecords.ts | 15 ++++++-- .../pds/src/api/com/atproto/repo/putRecord.ts | 16 ++++++--- packages/pds/src/api/proxy.ts | 35 ++++++++++++++++++- packages/pds/src/services/account/index.ts | 1 + 9 files changed, 112 insertions(+), 41 deletions(-) diff --git a/packages/pds/src/api/com/atproto/repo/applyWrites.ts b/packages/pds/src/api/com/atproto/repo/applyWrites.ts index c6537caec65..4f7d5720045 100644 --- a/packages/pds/src/api/com/atproto/repo/applyWrites.ts +++ b/packages/pds/src/api/com/atproto/repo/applyWrites.ts @@ -15,7 +15,7 @@ import { } from '../../../../repo' import AppContext from '../../../../context' import { ConcurrentWriteError } from '../../../../services/repo' -import { proxy, authPassthru } from '../../../proxy' +import { proxy, authPassthru, ensureThisPds } from '../../../proxy' const ratelimitPoints = ({ input }: { input: HandlerInput }) => { let points = 0 @@ -60,11 +60,14 @@ export default function (server: Server, ctx: AppContext) { const tx = input.body const { repo, validate, swapCommit } = tx - const did = await ctx.services.account(ctx.db).getDidForActor(repo) - - if (!did) { + const account = await ctx.services.account(ctx.db).getAccount(repo) + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } + + const { did, pdsDid } = account + ensureThisPds(ctx, pdsDid) + 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 83dda1224c9..8883ac08813 100644 --- a/packages/pds/src/api/com/atproto/repo/createRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/createRecord.ts @@ -12,7 +12,12 @@ import AppContext from '../../../../context' import { ids } from '../../../../lexicon/lexicons' import Database from '../../../../db' import { ConcurrentWriteError } from '../../../../services/repo' -import { proxy, resultPassthru, authPassthru } from '../../../proxy' +import { + proxy, + resultPassthru, + authPassthru, + ensureThisPds, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.createRecord({ @@ -43,11 +48,14 @@ export default function (server: Server, ctx: AppContext) { const { repo, collection, rkey, record, swapCommit, validate } = input.body - const did = await ctx.services.account(ctx.db).getDidForActor(repo) - - if (!did) { + const account = await ctx.services.account(ctx.db).getAccount(repo) + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } + + const { did, pdsDid } = account + ensureThisPds(ctx, pdsDid) + 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 28213aeaa4c..c906dde65c3 100644 --- a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts @@ -5,7 +5,7 @@ import AppContext from '../../../../context' import { BadCommitSwapError, BadRecordSwapError } from '../../../../repo' import { CID } from 'multiformats/cid' import { ConcurrentWriteError } from '../../../../services/repo' -import { proxy, authPassthru } from '../../../proxy' +import { proxy, authPassthru, ensureThisPds } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.deleteRecord({ @@ -34,11 +34,14 @@ export default function (server: Server, ctx: AppContext) { } const { repo, collection, rkey, swapCommit, swapRecord } = input.body - const did = await ctx.services.account(ctx.db).getDidForActor(repo) - - if (!did) { + const account = await ctx.services.account(ctx.db).getAccount(repo) + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } + + const { did, pdsDid } = account + ensureThisPds(ctx, pdsDid) + if (did !== auth.credentials.did) { throw new AuthRequiredError() } diff --git a/packages/pds/src/api/com/atproto/repo/describeRepo.ts b/packages/pds/src/api/com/atproto/repo/describeRepo.ts index b340314ef77..408ed68f202 100644 --- a/packages/pds/src/api/com/atproto/repo/describeRepo.ts +++ b/packages/pds/src/api/com/atproto/repo/describeRepo.ts @@ -2,16 +2,25 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import * as id from '@atproto/identity' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { proxyUnauthed, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.describeRepo(async ({ params }) => { const { repo } = params - - const account = await ctx.services.account(ctx.db).getAccount(repo) + const accountService = ctx.services.account(ctx.db) + const account = await accountService.getAccount(repo) if (account === null) { throw new InvalidRequestError(`Could not find user: ${repo}`) } + const proxied = await proxyUnauthed(ctx, account.pdsDid, async (agent) => { + const result = await agent.api.com.atproto.repo.describeRepo(params) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + let didDoc try { didDoc = await ctx.idResolver.did.ensureResolve(account.did) diff --git a/packages/pds/src/api/com/atproto/repo/getRecord.ts b/packages/pds/src/api/com/atproto/repo/getRecord.ts index 5c99a7226c1..458f97b6540 100644 --- a/packages/pds/src/api/com/atproto/repo/getRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/getRecord.ts @@ -2,35 +2,32 @@ import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' +import { isThisPds, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.getRecord(async ({ params }) => { const { repo, collection, rkey, cid } = params - const did = await ctx.services.account(ctx.db).getDidForActor(repo) + const account = await ctx.services.account(ctx.db).getAccount(repo) // fetch from pds if available, if not then fetch from appview - if (did) { - const uri = AtUri.make(did, collection, rkey) - const record = await ctx.services - .record(ctx.db) - .getRecord(uri, cid || null) - if (!record || record.takedownId !== null) { - throw new InvalidRequestError(`Could not locate record: ${uri}`) - } - return { - encoding: 'application/json', - body: { - uri: uri.toString(), - cid: record.cid, - value: record.value, - }, - } + if (!account || !isThisPds(ctx, account.pdsDid)) { + const res = await ctx.appViewAgent.api.com.atproto.repo.getRecord(params) + return resultPassthru(res) + } + + const uri = AtUri.make(account.did, collection, rkey) + const record = await ctx.services.record(ctx.db).getRecord(uri, cid || null) + if (!record || record.takedownId !== null) { + throw new InvalidRequestError(`Could not locate record: ${uri}`) } - const res = await ctx.appViewAgent.api.com.atproto.repo.getRecord(params) return { encoding: 'application/json', - body: res.data, + body: { + uri: uri.toString(), + cid: record.cid, + value: record.value, + }, } }) } diff --git a/packages/pds/src/api/com/atproto/repo/listRecords.ts b/packages/pds/src/api/com/atproto/repo/listRecords.ts index 8c2669ff010..6adf803d99d 100644 --- a/packages/pds/src/api/com/atproto/repo/listRecords.ts +++ b/packages/pds/src/api/com/atproto/repo/listRecords.ts @@ -2,6 +2,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { proxyUnauthed, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.listRecords(async ({ params }) => { @@ -15,13 +16,21 @@ export default function (server: Server, ctx: AppContext) { reverse = false, } = params - const did = await ctx.services.account(ctx.db).getDidForActor(repo) - if (!did) { + const account = await ctx.services.account(ctx.db).getAccount(repo) + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } + const proxied = await proxyUnauthed(ctx, account.pdsDid, async (agent) => { + const result = await agent.api.com.atproto.repo.listRecords(params) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + const records = await ctx.services.record(ctx.db).listRecordsForCollection({ - did, + did: account.did, collection, limit, reverse, diff --git a/packages/pds/src/api/com/atproto/repo/putRecord.ts b/packages/pds/src/api/com/atproto/repo/putRecord.ts index 4dfcfa23586..d2e8d12a745 100644 --- a/packages/pds/src/api/com/atproto/repo/putRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/putRecord.ts @@ -12,7 +12,12 @@ import { PreparedUpdate, } from '../../../../repo' import { ConcurrentWriteError } from '../../../../services/repo' -import { proxy, resultPassthru, authPassthru } from '../../../proxy' +import { + proxy, + resultPassthru, + authPassthru, + ensureThisPds, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.putRecord({ @@ -50,11 +55,14 @@ export default function (server: Server, ctx: AppContext) { swapCommit, swapRecord, } = input.body - const did = await ctx.services.account(ctx.db).getDidForActor(repo) - - if (!did) { + const account = await ctx.services.account(ctx.db).getAccount(repo) + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } + + const { did, pdsDid } = account + ensureThisPds(ctx, pdsDid) + if (did !== auth.credentials.did) { throw new AuthRequiredError() } diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index f854004d7bd..306edf6b576 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -10,7 +10,7 @@ export const proxy = async ( fn: (agent: AtpAgent) => Promise, ): Promise => { if (!creds.audience || creds.audience === ctx.cfg.service.did) { - return null + return null // credentials are for this pds, skip proxying } const accountService = ctx.services.account(ctx.db) const pds = await accountService.getPds(creds.audience) @@ -38,6 +38,39 @@ export const proxy = async ( } } +export const proxyUnauthed = async ( + ctx: AppContext, + pdsDid: string | null, + fn: (agent: AtpAgent) => Promise, +): Promise => { + if (isThisPds(ctx, pdsDid)) { + return null // credentials are for this pds, skip proxying + } + const accountService = ctx.services.account(ctx.db) + const pds = pdsDid && (await accountService.getPds(pdsDid)) + if (!pds) { + throw new UpstreamFailureError('unknown pds') + } + const agent = new AtpAgent({ service: `https://${pds.host}` }) + return fn(agent) +} + +export const isThisPds = (ctx: AppContext, pdsDid: string | null) => { + return !pdsDid || pdsDid === ctx.cfg.service.did +} + +// @NOTE on the identity service this serves a 400 w/ ExpiredToken to prompt a refresh flow from the client. +// but on our other PDSes the same case should be a 403 w/ AccountNotFound, assuming their access token verifies. +export const ensureThisPds = (ctx: AppContext, pdsDid: string | null) => { + if (!isThisPds(ctx, pdsDid)) { + // instruct client to refresh token during potential account migration + throw new InvalidRequestError( + 'Token audience is out of date', + 'ExpiredToken', + ) + } +} + export const resultPassthru = (result: { headers: Headers; data: T }) => { // @TODO pass through any headers that we always want to forward along return { diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index c381881b466..ea1ae85aeaa 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -22,6 +22,7 @@ export class AccountService { return (db: Database) => new AccountService(db) } + // @TODO decouple account from repo_root, move takedownId. async getAccount( handleOrDid: string, includeSoftDeleted = false, From 25e08b8fc582d976971d1858b1d372c2dbbad74e Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 6 Oct 2023 16:49:44 -0400 Subject: [PATCH 007/135] unify authed and unauthed pds proxying --- .../src/api/com/atproto/repo/applyWrites.ts | 16 ++++++---- .../src/api/com/atproto/repo/createRecord.ts | 18 ++++++----- .../src/api/com/atproto/repo/deleteRecord.ts | 16 ++++++---- .../src/api/com/atproto/repo/describeRepo.ts | 4 +-- .../src/api/com/atproto/repo/listRecords.ts | 4 +-- .../pds/src/api/com/atproto/repo/putRecord.ts | 18 ++++++----- .../src/api/com/atproto/repo/uploadBlob.ts | 18 ++++++----- packages/pds/src/api/proxy.ts | 30 +++++-------------- 8 files changed, 65 insertions(+), 59 deletions(-) diff --git a/packages/pds/src/api/com/atproto/repo/applyWrites.ts b/packages/pds/src/api/com/atproto/repo/applyWrites.ts index 4f7d5720045..7de0cef7b8f 100644 --- a/packages/pds/src/api/com/atproto/repo/applyWrites.ts +++ b/packages/pds/src/api/com/atproto/repo/applyWrites.ts @@ -48,12 +48,16 @@ export default function (server: Server, ctx: AppContext) { ], handler: async ({ input, auth, req }) => { - const proxied = await proxy(ctx, auth.credentials, async (agent) => { - await agent.api.com.atproto.repo.applyWrites( - input.body, - authPassthru(req, true), - ) - }) + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.com.atproto.repo.applyWrites( + input.body, + authPassthru(req, true), + ) + }, + ) if (proxied !== null) { return proxied } diff --git a/packages/pds/src/api/com/atproto/repo/createRecord.ts b/packages/pds/src/api/com/atproto/repo/createRecord.ts index 8883ac08813..b76f5d3cb17 100644 --- a/packages/pds/src/api/com/atproto/repo/createRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/createRecord.ts @@ -35,13 +35,17 @@ export default function (server: Server, ctx: AppContext) { }, ], handler: async ({ input, auth, req }) => { - const proxied = await proxy(ctx, auth.credentials, async (agent) => { - const result = await agent.api.com.atproto.repo.createRecord( - input.body, - authPassthru(req, true), - ) - return resultPassthru(result) - }) + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.repo.createRecord( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) if (proxied !== null) { return proxied } diff --git a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts index c906dde65c3..5424606e889 100644 --- a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts @@ -23,12 +23,16 @@ export default function (server: Server, ctx: AppContext) { }, ], handler: async ({ input, auth, req }) => { - const proxied = await proxy(ctx, auth.credentials, async (agent) => { - await agent.api.com.atproto.repo.deleteRecord( - input.body, - authPassthru(req, true), - ) - }) + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.com.atproto.repo.deleteRecord( + input.body, + authPassthru(req, true), + ) + }, + ) if (proxied !== null) { return proxied } diff --git a/packages/pds/src/api/com/atproto/repo/describeRepo.ts b/packages/pds/src/api/com/atproto/repo/describeRepo.ts index 408ed68f202..6e596f9a6a4 100644 --- a/packages/pds/src/api/com/atproto/repo/describeRepo.ts +++ b/packages/pds/src/api/com/atproto/repo/describeRepo.ts @@ -2,7 +2,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import * as id from '@atproto/identity' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { proxyUnauthed, resultPassthru } from '../../../proxy' +import { proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.describeRepo(async ({ params }) => { @@ -13,7 +13,7 @@ export default function (server: Server, ctx: AppContext) { throw new InvalidRequestError(`Could not find user: ${repo}`) } - const proxied = await proxyUnauthed(ctx, account.pdsDid, async (agent) => { + const proxied = await proxy(ctx, account.pdsDid, async (agent) => { const result = await agent.api.com.atproto.repo.describeRepo(params) return resultPassthru(result) }) diff --git a/packages/pds/src/api/com/atproto/repo/listRecords.ts b/packages/pds/src/api/com/atproto/repo/listRecords.ts index 6adf803d99d..b60bbf844a0 100644 --- a/packages/pds/src/api/com/atproto/repo/listRecords.ts +++ b/packages/pds/src/api/com/atproto/repo/listRecords.ts @@ -2,7 +2,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { proxyUnauthed, resultPassthru } from '../../../proxy' +import { proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.listRecords(async ({ params }) => { @@ -21,7 +21,7 @@ export default function (server: Server, ctx: AppContext) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } - const proxied = await proxyUnauthed(ctx, account.pdsDid, async (agent) => { + const proxied = await proxy(ctx, account.pdsDid, async (agent) => { const result = await agent.api.com.atproto.repo.listRecords(params) return resultPassthru(result) }) diff --git a/packages/pds/src/api/com/atproto/repo/putRecord.ts b/packages/pds/src/api/com/atproto/repo/putRecord.ts index d2e8d12a745..0f827b6d908 100644 --- a/packages/pds/src/api/com/atproto/repo/putRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/putRecord.ts @@ -35,13 +35,17 @@ export default function (server: Server, ctx: AppContext) { }, ], handler: async ({ auth, input, req }) => { - const proxied = await proxy(ctx, auth.credentials, async (agent) => { - const result = await agent.api.com.atproto.repo.putRecord( - input.body, - authPassthru(req, true), - ) - return resultPassthru(result) - }) + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.repo.putRecord( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) if (proxied !== null) { return proxied } diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index 90aee09cad8..4b722889a1c 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -7,13 +7,17 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.uploadBlob({ auth: ctx.accessVerifierCheckTakedown, handler: async ({ auth, input, req }) => { - const proxied = await proxy(ctx, auth.credentials, async (agent) => { - const result = await agent.api.com.atproto.repo.uploadBlob( - await streamToBytes(input.body), // @TODO proxy streaming - authPassthru(req, true), - ) - return resultPassthru(result) - }) + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.repo.uploadBlob( + await streamToBytes(input.body), // @TODO proxy streaming + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) if (proxied !== null) { return proxied } diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 306edf6b576..9d46637e5e8 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -6,16 +6,16 @@ import AppContext from '../context' export const proxy = async ( ctx: AppContext, - creds: { audience?: string }, + pdsDid: string | null | undefined, fn: (agent: AtpAgent) => Promise, ): Promise => { - if (!creds.audience || creds.audience === ctx.cfg.service.did) { - return null // credentials are for this pds, skip proxying + if (isThisPds(ctx, pdsDid)) { + return null // skip proxying } const accountService = ctx.services.account(ctx.db) - const pds = await accountService.getPds(creds.audience) + const pds = pdsDid && (await accountService.getPds(pdsDid)) if (!pds) { - throw new UpstreamFailureError('unknown pds in credentials') + throw new UpstreamFailureError('unknown pds') } // @TODO reuse agents const agent = new AtpAgent({ service: `https://${pds.host}` }) @@ -38,24 +38,10 @@ export const proxy = async ( } } -export const proxyUnauthed = async ( +export const isThisPds = ( ctx: AppContext, - pdsDid: string | null, - fn: (agent: AtpAgent) => Promise, -): Promise => { - if (isThisPds(ctx, pdsDid)) { - return null // credentials are for this pds, skip proxying - } - const accountService = ctx.services.account(ctx.db) - const pds = pdsDid && (await accountService.getPds(pdsDid)) - if (!pds) { - throw new UpstreamFailureError('unknown pds') - } - const agent = new AtpAgent({ service: `https://${pds.host}` }) - return fn(agent) -} - -export const isThisPds = (ctx: AppContext, pdsDid: string | null) => { + pdsDid: string | null | undefined, +) => { return !pdsDid || pdsDid === ctx.cfg.service.did } From e59beca866cb0671c733cd26e187cb219f435924 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Sun, 8 Oct 2023 17:45:29 -0400 Subject: [PATCH 008/135] adapt admin endpoints for multi-pds --- .../src/api/com/atproto/admin/getRecord.ts | 36 +++++++++++-------- .../pds/src/api/com/atproto/admin/getRepo.ts | 6 ++-- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/pds/src/api/com/atproto/admin/getRecord.ts b/packages/pds/src/api/com/atproto/admin/getRecord.ts index 0ebf1013c48..8ba48308f8e 100644 --- a/packages/pds/src/api/com/atproto/admin/getRecord.ts +++ b/packages/pds/src/api/com/atproto/admin/getRecord.ts @@ -11,27 +11,27 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ req, params, auth }) => { const access = auth.credentials const { db, services } = ctx - const { uri, cid } = params - const result = await services - .record(db) - .getRecord(new AtUri(uri), cid ?? null, true) - const recordDetail = - result && - (await services.moderation(db).views.recordDetail(result, { - includeEmails: access.moderator, - })) + const { uri: uriStr, cid } = params + const uri = new AtUri(uriStr) if (ctx.cfg.bskyAppView.proxyModeration) { try { - const { data: recordDetailAppview } = - await ctx.appViewAgent.com.atproto.admin.getRecord( + const [{ data: recordDetailAppview }, account] = await Promise.all([ + ctx.appViewAgent.com.atproto.admin.getRecord( params, authPassthru(req), - ) - if (recordDetail) { + ), + services.account(db).getAccount(uri.host, true), + ]) + const localRepoView = + account && + (await services.moderation(db).views.repo(account, { + includeEmails: access.moderator, + })) + if (localRepoView) { recordDetailAppview.repo = mergeRepoViewPdsDetails( recordDetailAppview.repo, - recordDetail.repo, + localRepoView, ) } return { @@ -47,6 +47,14 @@ export default function (server: Server, ctx: AppContext) { } } + // @TODO when proxying fetch repo info directly rather than via record + const result = await services.record(db).getRecord(uri, cid ?? null, true) + const recordDetail = + result && + (await services.moderation(db).views.recordDetail(result, { + includeEmails: access.moderator, + })) + if (!recordDetail) { throw new InvalidRequestError('Record not found', 'RecordNotFound') } diff --git a/packages/pds/src/api/com/atproto/admin/getRepo.ts b/packages/pds/src/api/com/atproto/admin/getRepo.ts index fdca03c1759..fed516916dc 100644 --- a/packages/pds/src/api/com/atproto/admin/getRepo.ts +++ b/packages/pds/src/api/com/atproto/admin/getRepo.ts @@ -11,10 +11,10 @@ export default function (server: Server, ctx: AppContext) { const access = auth.credentials const { db, services } = ctx const { did } = params - const result = await services.account(db).getAccount(did, true) + const account = await services.account(db).getAccount(did, true) const repoDetail = - result && - (await services.moderation(db).views.repoDetail(result, { + account && + (await services.moderation(db).views.repoDetail(account, { includeEmails: access.moderator, })) From 92f3434e2bda89cadf93d75c21d487cbb8b331c4 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Sun, 8 Oct 2023 23:59:35 -0400 Subject: [PATCH 009/135] pds-proxy report creation and app.bsky endpoints --- .../src/api/app/bsky/actor/getPreferences.ts | 1 + .../pds/src/api/app/bsky/actor/getProfile.ts | 19 ++++++++++++++++++- .../pds/src/api/app/bsky/actor/getProfiles.ts | 18 +++++++++++++++++- .../src/api/app/bsky/actor/getSuggestions.ts | 18 +++++++++++++++++- .../src/api/app/bsky/actor/putPreferences.ts | 1 + .../src/api/app/bsky/actor/searchActors.ts | 18 +++++++++++++++++- .../app/bsky/actor/searchActorsTypeahead.ts | 18 +++++++++++++++++- .../src/api/app/bsky/feed/getActorFeeds.ts | 18 +++++++++++++++++- .../src/api/app/bsky/feed/getActorLikes.ts | 19 ++++++++++++++++++- .../src/api/app/bsky/feed/getAuthorFeed.ts | 19 ++++++++++++++++++- packages/pds/src/api/app/bsky/feed/getFeed.ts | 18 +++++++++++++++++- .../src/api/app/bsky/feed/getFeedGenerator.ts | 18 +++++++++++++++++- .../api/app/bsky/feed/getFeedGenerators.ts | 18 +++++++++++++++++- .../pds/src/api/app/bsky/feed/getLikes.ts | 18 +++++++++++++++++- .../pds/src/api/app/bsky/feed/getListFeed.ts | 18 +++++++++++++++++- .../src/api/app/bsky/feed/getPostThread.ts | 19 ++++++++++++++++++- .../pds/src/api/app/bsky/feed/getPosts.ts | 18 +++++++++++++++++- .../src/api/app/bsky/feed/getRepostedBy.ts | 18 +++++++++++++++++- .../api/app/bsky/feed/getSuggestedFeeds.ts | 18 +++++++++++++++++- .../pds/src/api/app/bsky/feed/getTimeline.ts | 18 +++++++++++++++++- .../pds/src/api/app/bsky/graph/getBlocks.ts | 18 +++++++++++++++++- .../src/api/app/bsky/graph/getFollowers.ts | 19 ++++++++++++++++++- .../pds/src/api/app/bsky/graph/getFollows.ts | 19 ++++++++++++++++++- .../pds/src/api/app/bsky/graph/getList.ts | 18 +++++++++++++++++- .../src/api/app/bsky/graph/getListBlocks.ts | 18 +++++++++++++++++- .../src/api/app/bsky/graph/getListMutes.ts | 18 +++++++++++++++++- .../pds/src/api/app/bsky/graph/getLists.ts | 18 +++++++++++++++++- .../pds/src/api/app/bsky/graph/getMutes.ts | 18 +++++++++++++++++- .../bsky/graph/getSuggestedFollowsByActor.ts | 19 ++++++++++++++++++- .../pds/src/api/app/bsky/graph/muteActor.ts | 18 ++++++++++++++++-- .../src/api/app/bsky/graph/muteActorList.ts | 18 ++++++++++++++++-- .../pds/src/api/app/bsky/graph/unmuteActor.ts | 18 ++++++++++++++++-- .../src/api/app/bsky/graph/unmuteActorList.ts | 18 ++++++++++++++++-- .../app/bsky/notification/getUnreadCount.ts | 18 +++++++++++++++++- .../bsky/notification/listNotifications.ts | 19 ++++++++++++++++++- .../api/app/bsky/notification/registerPush.ts | 17 ++++++++++++++++- .../api/app/bsky/notification/updateSeen.ts | 18 ++++++++++++++++-- .../src/api/app/bsky/unspecced/getPopular.ts | 18 +++++++++++++++++- .../unspecced/getPopularFeedGenerators.ts | 19 ++++++++++++++++++- .../com/atproto/moderation/createReport.ts | 18 +++++++++++++++++- .../api/com/atproto/server/createAccount.ts | 2 +- 41 files changed, 652 insertions(+), 44 deletions(-) diff --git a/packages/pds/src/api/app/bsky/actor/getPreferences.ts b/packages/pds/src/api/app/bsky/actor/getPreferences.ts index 1bca50f0bd1..415114cb548 100644 --- a/packages/pds/src/api/app/bsky/actor/getPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/getPreferences.ts @@ -2,6 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { AuthScope } from '../../../../auth' +// @TODO may need to proxy to pds export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getPreferences({ auth: ctx.accessVerifier, diff --git a/packages/pds/src/api/app/bsky/actor/getProfile.ts b/packages/pds/src/api/app/bsky/actor/getProfile.ts index afb4df09fbb..648ad204f92 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfile.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfile.ts @@ -3,12 +3,29 @@ import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/actor/getProfile' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' -import { authPassthru } from '../../../proxy' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getProfile({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, auth, params }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.getProfile( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.actor.getProfile( diff --git a/packages/pds/src/api/app/bsky/actor/getProfiles.ts b/packages/pds/src/api/app/bsky/actor/getProfiles.ts index ebec9e36938..ed5c10a6552 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfiles.ts @@ -2,12 +2,28 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { OutputSchema } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import { LocalRecords } from '../../../../services/local' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' import { handleReadAfterWrite } from '../util/read-after-write' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getProfiles({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.getProfiles( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.getProfiles( params, diff --git a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts index e6c72e5c830..c2dd11cfd98 100644 --- a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getSuggestions({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.getSuggestions( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.getSuggestions( params, diff --git a/packages/pds/src/api/app/bsky/actor/putPreferences.ts b/packages/pds/src/api/app/bsky/actor/putPreferences.ts index 27528595116..ce2fd5e39cb 100644 --- a/packages/pds/src/api/app/bsky/actor/putPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/putPreferences.ts @@ -3,6 +3,7 @@ import AppContext from '../../../../context' import { UserPreference } from '../../../../services/account' import { InvalidRequestError } from '@atproto/xrpc-server' +// @TODO may need to proxy to pds export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.putPreferences({ auth: ctx.accessVerifierCheckTakedown, diff --git a/packages/pds/src/api/app/bsky/actor/searchActors.ts b/packages/pds/src/api/app/bsky/actor/searchActors.ts index 3f1bd2355d6..c8b9c52a726 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActors.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActors.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActors({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.searchActors( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.searchActors( params, diff --git a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts index a637aea69c7..92e52b66f66 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActorsTypeahead({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.searchActorsTypeahead( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.searchActorsTypeahead( diff --git a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts index da99617178f..fb706591cb2 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getActorFeeds({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getActorFeeds( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getActorFeeds( params, diff --git a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts index 57696e382b9..786777cfde9 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts @@ -3,12 +3,29 @@ import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' -import { authPassthru } from '../../../proxy' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getActorLikes({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getActorLikes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null diff --git a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts index f07200c0bd2..a19cea98bc7 100644 --- a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts @@ -4,12 +4,29 @@ import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorF import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' import { isReasonRepost } from '../../../../lexicon/types/app/bsky/feed/defs' -import { authPassthru } from '../../../proxy' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getAuthorFeed({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getAuthorFeed( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.feed.getAuthorFeed( diff --git a/packages/pds/src/api/app/bsky/feed/getFeed.ts b/packages/pds/src/api/app/bsky/feed/getFeed.ts index 051b0c7bcdf..fa7a8ec91a6 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeed.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeed({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getFeed( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const { data: feed } = diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts index 28c404b58e8..c55bceb608d 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerator({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getFeedGenerator( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getFeedGenerator( params, diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts index 12cf9e91c0a..083dd510107 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerators({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getFeedGenerators( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getFeedGenerators( params, diff --git a/packages/pds/src/api/app/bsky/feed/getLikes.ts b/packages/pds/src/api/app/bsky/feed/getLikes.ts index 771cc511cd4..37bb74461cf 100644 --- a/packages/pds/src/api/app/bsky/feed/getLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getLikes.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getLikes({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getLikes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getLikes( params, diff --git a/packages/pds/src/api/app/bsky/feed/getListFeed.ts b/packages/pds/src/api/app/bsky/feed/getListFeed.ts index 34b8630a933..8b7ce45d5dc 100644 --- a/packages/pds/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getListFeed.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getListFeed({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getListFeed( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getListFeed( params, diff --git a/packages/pds/src/api/app/bsky/feed/getPostThread.ts b/packages/pds/src/api/app/bsky/feed/getPostThread.ts index 4189524c869..34afae8223f 100644 --- a/packages/pds/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/pds/src/api/app/bsky/feed/getPostThread.ts @@ -22,12 +22,29 @@ import { getRepoRev, handleReadAfterWrite, } from '../util/read-after-write' -import { authPassthru } from '../../../proxy' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getPostThread({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getPostThread( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null diff --git a/packages/pds/src/api/app/bsky/feed/getPosts.ts b/packages/pds/src/api/app/bsky/feed/getPosts.ts index 1b755450f63..140844dec54 100644 --- a/packages/pds/src/api/app/bsky/feed/getPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/getPosts.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getPosts({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getPosts( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getPosts( params, diff --git a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts index 30e72b434e6..3936fcdbb67 100644 --- a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getRepostedBy({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getRepostedBy( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getRepostedBy( params, diff --git a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts index 733405b3b42..de3fc730880 100644 --- a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getSuggestedFeeds({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getSuggestedFeeds( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getSuggestedFeeds( params, diff --git a/packages/pds/src/api/app/bsky/feed/getTimeline.ts b/packages/pds/src/api/app/bsky/feed/getTimeline.ts index 2c3e2ed44d6..f6ea5254f16 100644 --- a/packages/pds/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/pds/src/api/app/bsky/feed/getTimeline.ts @@ -3,11 +3,27 @@ import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getTimeline' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getTimeline({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getTimeline( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getTimeline( params, diff --git a/packages/pds/src/api/app/bsky/graph/getBlocks.ts b/packages/pds/src/api/app/bsky/graph/getBlocks.ts index f66eb64b945..788388f9446 100644 --- a/packages/pds/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getBlocks.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getBlocks({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getBlocks( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getBlocks( params, diff --git a/packages/pds/src/api/app/bsky/graph/getFollowers.ts b/packages/pds/src/api/app/bsky/graph/getFollowers.ts index f438e95251b..d187709311b 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollowers.ts @@ -1,11 +1,28 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../proxy' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollowers({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getFollowers( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.graph.getFollowers( diff --git a/packages/pds/src/api/app/bsky/graph/getFollows.ts b/packages/pds/src/api/app/bsky/graph/getFollows.ts index 91684f65e38..7ad08e8eaf4 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollows.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollows.ts @@ -1,11 +1,28 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../proxy' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollows({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getFollows( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.graph.getFollows( diff --git a/packages/pds/src/api/app/bsky/graph/getList.ts b/packages/pds/src/api/app/bsky/graph/getList.ts index 061d6759c2c..919d149c428 100644 --- a/packages/pds/src/api/app/bsky/graph/getList.ts +++ b/packages/pds/src/api/app/bsky/graph/getList.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getList({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getList( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getList( params, diff --git a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts index 83975782fa4..17d112f52d7 100644 --- a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListBlocks({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getListBlocks( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getListBlocks( params, diff --git a/packages/pds/src/api/app/bsky/graph/getListMutes.ts b/packages/pds/src/api/app/bsky/graph/getListMutes.ts index 05f6ce1ab09..9b023007c90 100644 --- a/packages/pds/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getListMutes.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListMutes({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getListMutes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getListMutes( params, diff --git a/packages/pds/src/api/app/bsky/graph/getLists.ts b/packages/pds/src/api/app/bsky/graph/getLists.ts index 6c8f6452ea4..b752f09dd91 100644 --- a/packages/pds/src/api/app/bsky/graph/getLists.ts +++ b/packages/pds/src/api/app/bsky/graph/getLists.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getLists({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getLists( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getLists( params, diff --git a/packages/pds/src/api/app/bsky/graph/getMutes.ts b/packages/pds/src/api/app/bsky/graph/getMutes.ts index 12ff1a032a0..cdd666a912c 100644 --- a/packages/pds/src/api/app/bsky/graph/getMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getMutes.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getMutes({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getMutes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getMutes( params, diff --git a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index 53125cbc517..7ca10f644ae 100644 --- a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -1,10 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getSuggestedFollowsByActor({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = + await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getSuggestedFollowsByActor( diff --git a/packages/pds/src/api/app/bsky/graph/muteActor.ts b/packages/pds/src/api/app/bsky/graph/muteActor.ts index 9f753bac926..5278b6d8323 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActor.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActor({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.muteActor( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.muteActor(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/muteActorList.ts b/packages/pds/src/api/app/bsky/graph/muteActorList.ts index 441571a26b9..20430074774 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActorList.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActorList({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.muteActorList( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.muteActorList(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts index 586b12565d6..87fee4a19f6 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActor({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.unmuteActor( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.unmuteActor(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts index e8ba9f8c4d4..c387b799893 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActorList({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.unmuteActorList( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.unmuteActorList(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts index c8b723403d5..31be13b9dba 100644 --- a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.getUnreadCount({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.notification.getUnreadCount( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.notification.getUnreadCount( diff --git a/packages/pds/src/api/app/bsky/notification/listNotifications.ts b/packages/pds/src/api/app/bsky/notification/listNotifications.ts index 48e75304af5..6b3e4c964a8 100644 --- a/packages/pds/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/pds/src/api/app/bsky/notification/listNotifications.ts @@ -1,10 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.listNotifications({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = + await agent.api.app.bsky.notification.listNotifications( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.notification.listNotifications( diff --git a/packages/pds/src/api/app/bsky/notification/registerPush.ts b/packages/pds/src/api/app/bsky/notification/registerPush.ts index d5db39f1ac7..4c767c87728 100644 --- a/packages/pds/src/api/app/bsky/notification/registerPush.ts +++ b/packages/pds/src/api/app/bsky/notification/registerPush.ts @@ -4,11 +4,26 @@ import { getNotif } from '@atproto/identity' import { InvalidRequestError } from '@atproto/xrpc-server' import { AtpAgent } from '@atproto/api' import { getDidDoc } from '../util/resolver' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.registerPush({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.notification.registerPush( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const { serviceDid } = input.body const { credentials: { did }, diff --git a/packages/pds/src/api/app/bsky/notification/updateSeen.ts b/packages/pds/src/api/app/bsky/notification/updateSeen.ts index 44fe4bc13cc..9808926a7ba 100644 --- a/packages/pds/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/pds/src/api/app/bsky/notification/updateSeen.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.updateSeen({ auth: ctx.accessVerifier, - handler: async ({ input, auth }) => { - const requester = auth.credentials.did + handler: async ({ input, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.notification.updateSeen( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.notification.updateSeen(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopular.ts b/packages/pds/src/api/app/bsky/unspecced/getPopular.ts index f890ea7baed..dbe8a42738f 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopular.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopular.ts @@ -1,11 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getPopular({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.unspecced.getPopular( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const HOT_CLASSIC_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/hot-classic' diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index abc556cdb70..5ffbd417790 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -1,11 +1,28 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getPopularFeedGenerators({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = + await agent.api.app.bsky.unspecced.getPopularFeedGenerators( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.unspecced.getPopularFeedGenerators( diff --git a/packages/pds/src/api/com/atproto/moderation/createReport.ts b/packages/pds/src/api/com/atproto/moderation/createReport.ts index 83cd5f454e0..6d7d1493cdb 100644 --- a/packages/pds/src/api/com/atproto/moderation/createReport.ts +++ b/packages/pds/src/api/com/atproto/moderation/createReport.ts @@ -1,11 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { getReasonType, getSubject } from './util' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ auth: ctx.accessVerifierCheckTakedown, - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.moderation.createReport( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did if (ctx.cfg.bskyAppView.proxyModeration) { diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 11a012e4bb5..ae81e2995d3 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -123,7 +123,7 @@ export default function (server: Server, ctx: AppContext) { await ctx.services.auth(dbTxn).grantRefreshToken(refreshPayload, null) // Setup repo root - // @TODO contact pds for repo setup + // @TODO contact pds for repo setup, will look like createAccount but bringing own did await repoTxn.createRepo(did, [], now) return { From c9c6d0a0014d5d2a3af563ffdc18df9ffad32844 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 9 Oct 2023 14:06:20 -0400 Subject: [PATCH 010/135] fix --- packages/pds/src/api/com/atproto/server/createAccount.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index ae81e2995d3..b4201f19f4c 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -263,6 +263,7 @@ const getDidAndPlcOp = async ( // @TODO this implementation is a stub const assignPds = async (ctx: AppContext) => { const pdses = await ctx.db.db.selectFrom('pds').selectAll().execute() + if (!pdses.length) return const pds = pdses.at(randomInt(pdses.length)) return pds } From 7a62a2c70f98164621269f9df6c5d96c2a47956e Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 9 Oct 2023 14:15:13 -0400 Subject: [PATCH 011/135] move takedown id from repo_root to user_account, make repo_root optional for accounts --- .../api/com/atproto/server/createAccount.ts | 2 +- .../pds/src/api/com/atproto/sync/getBlob.ts | 16 ++++--- .../pds/src/api/com/atproto/sync/listRepos.ts | 3 +- .../20231004T040354739Z-user-account-pds.ts | 44 +++++++++++++++++++ packages/pds/src/db/tables/repo-root.ts | 1 - packages/pds/src/db/tables/user-account.ts | 1 + packages/pds/src/db/types.ts | 2 + packages/pds/src/services/account/index.ts | 31 +++++++------ packages/pds/src/services/moderation/index.ts | 4 +- packages/pds/src/services/moderation/views.ts | 19 ++++---- packages/pds/tests/db.test.ts | 1 - 11 files changed, 91 insertions(+), 33 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index b4201f19f4c..dfedd45ae9f 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -157,7 +157,7 @@ export const ensureCodeIsAvailable = async ( .selectAll() .whereNotExists((qb) => qb - .selectFrom('repo_root') + .selectFrom('user_account') .selectAll() .where('takedownId', 'is not', null) .whereRef('did', '=', ref('invite_code.forUser')), diff --git a/packages/pds/src/api/com/atproto/sync/getBlob.ts b/packages/pds/src/api/com/atproto/sync/getBlob.ts index b92154af80f..241b55fd808 100644 --- a/packages/pds/src/api/com/atproto/sync/getBlob.ts +++ b/packages/pds/src/api/com/atproto/sync/getBlob.ts @@ -11,10 +11,20 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.optionalAccessOrRoleVerifier, handler: async ({ params, res, auth }) => { const { ref } = ctx.db.db.dynamic + const { did } = params + + if (!isUserOrAdmin(auth, did)) { + const available = await ctx.services + .account(ctx.db) + .isRepoAvailable(did) + if (!available) { + throw new InvalidRequestError(`Could not find repo for DID: ${did}`) + } + } + const found = await ctx.db.db .selectFrom('blob') .selectAll() - .innerJoin('repo_root', 'repo_root.did', 'blob.creator') .innerJoin('repo_blob', (join) => join .onRef('repo_blob.cid', '=', 'blob.cid') @@ -23,10 +33,6 @@ export default function (server: Server, ctx: AppContext) { .where('blob.cid', '=', params.cid) .where('blob.creator', '=', params.did) .where(notSoftDeletedClause(ref('repo_blob'))) - .if(!isUserOrAdmin(auth, params.did), (qb) => - // takedown check for anyone other than an admin or the user - qb.where(notSoftDeletedClause(ref('repo_root'))), - ) .executeTakeFirst() if (!found) { throw new InvalidRequestError('Blob not found') diff --git a/packages/pds/src/api/com/atproto/sync/listRepos.ts b/packages/pds/src/api/com/atproto/sync/listRepos.ts index 597b949449f..77e77fafeb4 100644 --- a/packages/pds/src/api/com/atproto/sync/listRepos.ts +++ b/packages/pds/src/api/com/atproto/sync/listRepos.ts @@ -8,10 +8,11 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.sync.listRepos(async ({ params }) => { const { limit, cursor } = params const { ref } = ctx.db.db.dynamic + // @NOTE this join will become sparse as accounts move off, pagination will become relatively expensive. let builder = ctx.db.db .selectFrom('user_account') .innerJoin('repo_root', 'repo_root.did', 'user_account.did') - .where(notSoftDeletedClause(ref('repo_root'))) + .where(notSoftDeletedClause(ref('user_account'))) .select([ 'user_account.did as did', 'repo_root.root as head', diff --git a/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts index c293b0694c9..c5ae5c86067 100644 --- a/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts +++ b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts @@ -18,8 +18,52 @@ export async function up(db: Kysely, dialect: Dialect): Promise { .alterTable('user_account') .addColumn('pdsId', 'integer', (col) => col.references('pds.id')) .execute() + await db.schema + .alterTable('user_account') + .addColumn('takedownId', 'integer') + .execute() + const migrationDb = db as Kysely + const { ref } = migrationDb.dynamic + await migrationDb + .updateTable('user_account') + .where( + 'did', + 'in', + migrationDb + .selectFrom('repo_root') + .select('repo_root.did') + .where('takedownId', 'is not', null), + ) + .set({ + takedownId: migrationDb + .selectFrom('repo_root') + .select('repo_root.takedownId') + .whereRef('did', '=', ref('user_account.did')), + }) + .execute() + // when running manually, ensure to drop column only after it's completely out of use in read path + await db.schema.alterTable('repo_root').dropColumn('takedownId').execute() } + export async function down(db: Kysely): Promise { + await db.schema + .alterTable('repo_root') + .addColumn('takedownId', 'integer') + .execute() + // @NOTE no data migration for takedownId here + await db.schema.alterTable('user_account').dropColumn('takedownId').execute() await db.schema.alterTable('user_account').dropColumn('pdsId').execute() await db.schema.dropTable('pds').execute() } + +type MigrationSchema = { repo_root: RepoRoot; user_account: UserAccount } + +interface RepoRoot { + did: string + takedownId: number | null +} + +interface UserAccount { + did: string + takedownId: number | null +} diff --git a/packages/pds/src/db/tables/repo-root.ts b/packages/pds/src/db/tables/repo-root.ts index 6b6c921f380..801a90508de 100644 --- a/packages/pds/src/db/tables/repo-root.ts +++ b/packages/pds/src/db/tables/repo-root.ts @@ -4,7 +4,6 @@ export interface RepoRoot { root: string rev: string | null indexedAt: string - takedownId: number | null } export const tableName = 'repo_root' diff --git a/packages/pds/src/db/tables/user-account.ts b/packages/pds/src/db/tables/user-account.ts index 3b45b089a42..35aac9a7603 100644 --- a/packages/pds/src/db/tables/user-account.ts +++ b/packages/pds/src/db/tables/user-account.ts @@ -9,6 +9,7 @@ export interface UserAccount { invitesDisabled: Generated<0 | 1> inviteNote: string | null pdsId: number | null + takedownId: number | null } export type UserAccountEntry = Selectable diff --git a/packages/pds/src/db/types.ts b/packages/pds/src/db/types.ts index ce697cd7e60..a38e4abe230 100644 --- a/packages/pds/src/db/types.ts +++ b/packages/pds/src/db/types.ts @@ -1,3 +1,5 @@ import { DynamicReferenceBuilder } from 'kysely/dist/cjs/dynamic/dynamic-reference-builder' export type Ref = DynamicReferenceBuilder + +export type OptionalJoin = { [key in keyof T]: T[key] | null } diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index ea1ae85aeaa..98b124b1b8a 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -14,6 +14,7 @@ import * as sequencer from '../../sequencer' import { AppPassword } from '../../lexicon/types/com/atproto/server/createAppPassword' import { EmailTokenPurpose } from '../../db/tables/email-token' import { getRandomToken } from '../../api/com/atproto/server/util' +import { OptionalJoin } from '../../db/types' export class AccountService { constructor(public db: Database) {} @@ -31,10 +32,10 @@ export class AccountService { const result = await this.db.db .selectFrom('user_account') .innerJoin('did_handle', 'did_handle.did', 'user_account.did') - .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .leftJoin('repo_root', 'repo_root.did', 'did_handle.did') .leftJoin('pds', 'pds.id', 'user_account.pdsId') .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('repo_root'))), + qb.where(notSoftDeletedClause(ref('user_account'))), ) .where((qb) => { if (handleOrDid.startsWith('did:')) { @@ -60,10 +61,11 @@ export class AccountService { // Repo exists and is not taken-down async isRepoAvailable(did: string) { const found = await this.db.db - .selectFrom('repo_root') - .where('did', '=', did) - .where('takedownId', 'is', null) - .select('did') + .selectFrom('user_account') + .innerJoin('repo_root', 'repo_root.did', 'user_account.did') + .where('user_account.did', '=', did) + .where('user_account.takedownId', 'is', null) + .select('user_account.did') .executeTakeFirst() return found !== undefined } @@ -76,10 +78,10 @@ export class AccountService { const found = await this.db.db .selectFrom('user_account') .innerJoin('did_handle', 'did_handle.did', 'user_account.did') - .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .leftJoin('repo_root', 'repo_root.did', 'did_handle.did') .leftJoin('pds', 'pds.id', 'user_account.pdsId') .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('repo_root'))), + qb.where(notSoftDeletedClause(ref('user_account'))), ) .where('email', '=', email.toLowerCase()) .select(['pds.did as pdsDid']) @@ -104,9 +106,9 @@ export class AccountService { const { ref } = this.db.db.dynamic const found = await this.db.db .selectFrom('did_handle') - .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .innerJoin('user_account', 'user_account.did', 'did_handle.did') .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('repo_root'))), + qb.where(notSoftDeletedClause(ref('user_account'))), ) .where('handle', '=', handleOrDid) .select('did_handle.did') @@ -283,6 +285,7 @@ export class AccountService { .execute() } + // @NOTE only searches active repos, not all accounts. async search(opts: { query: string limit: number @@ -297,7 +300,7 @@ export class AccountService { .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') .innerJoin('user_account', 'user_account.did', 'did_handle.did') .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('repo_root'))), + qb.where(notSoftDeletedClause(ref('user_account'))), ) .where((qb) => { // sqlite doesn't support "ilike", but performs "like" case-insensitively @@ -324,6 +327,7 @@ export class AccountService { }).execute() } + // @NOTE only searches active repos, not all accounts. async list(opts: { limit: number cursor?: string @@ -336,8 +340,9 @@ export class AccountService { let builder = this.db.db .selectFrom('repo_root') .innerJoin('did_handle', 'did_handle.did', 'repo_root.did') + .innerJoin('user_account', 'user_account.did', 'repo_root.did') .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('repo_root'))), + qb.where(notSoftDeletedClause(ref('user_account'))), ) .selectAll('did_handle') .selectAll('repo_root') @@ -642,4 +647,4 @@ export type HandleSequenceToken = { did: string; handle: string } type AccountInfo = UserAccountEntry & DidHandle & - RepoRoot & { pdsDid: string | null } + OptionalJoin & { pdsDid: string | null } diff --git a/packages/pds/src/services/moderation/index.ts b/packages/pds/src/services/moderation/index.ts index 9e46332cf33..3f96d3a1b90 100644 --- a/packages/pds/src/services/moderation/index.ts +++ b/packages/pds/src/services/moderation/index.ts @@ -436,7 +436,7 @@ export class ModerationService { async takedownRepo(info: { takedownId: number; did: string }) { await this.db.db - .updateTable('repo_root') + .updateTable('user_account') .set({ takedownId: info.takedownId }) .where('did', '=', info.did) .where('takedownId', 'is', null) @@ -445,7 +445,7 @@ export class ModerationService { async reverseTakedownRepo(info: { did: string }) { await this.db.db - .updateTable('repo_root') + .updateTable('user_account') .set({ takedownId: null }) .where('did', '=', info.did) .execute() diff --git a/packages/pds/src/services/moderation/views.ts b/packages/pds/src/services/moderation/views.ts index e8d89620d73..e0285e6f932 100644 --- a/packages/pds/src/services/moderation/views.ts +++ b/packages/pds/src/services/moderation/views.ts @@ -21,6 +21,7 @@ import { AccountService } from '../account' import { RecordService } from '../record' import { ModerationReportRowWithHandle } from '.' import { ids } from '../../lexicon/lexicons' +import { OptionalJoin } from '../../db/types' export class ModerationViews { constructor(private db: Database) {} @@ -41,29 +42,29 @@ export class ModerationViews { const [info, actionResults, invitedBy] = await Promise.all([ await this.db.db - .selectFrom('did_handle') - .leftJoin('user_account', 'user_account.did', 'did_handle.did') + .selectFrom('user_account') .leftJoin('record as profile_record', (join) => join - .onRef('profile_record.did', '=', 'did_handle.did') + .onRef('profile_record.did', '=', 'user_account.did') .on('profile_record.collection', '=', ids.AppBskyActorProfile) .on('profile_record.rkey', '=', 'self'), ) .leftJoin('ipld_block as profile_block', (join) => join .onRef('profile_block.cid', '=', 'profile_record.cid') - .onRef('profile_block.creator', '=', 'did_handle.did'), + .onRef('profile_block.creator', '=', 'user_account.did'), ) .where( - 'did_handle.did', + 'user_account.did', 'in', results.map((r) => r.did), ) .select([ - 'did_handle.did as did', + 'user_account.did as did', 'user_account.email as email', 'user_account.invitesDisabled as invitesDisabled', 'user_account.inviteNote as inviteNote', + 'user_account.createdAt', 'profile_block.content as profileBytes', ]) .execute(), @@ -93,7 +94,7 @@ export class ModerationViews { ) const views = results.map((r) => { - const { email, invitesDisabled, profileBytes, inviteNote } = + const { email, invitesDisabled, profileBytes, inviteNote, createdAt } = infoByDid[r.did] ?? {} const action = actionByDid[r.did] const relatedRecords: object[] = [] @@ -105,7 +106,7 @@ export class ModerationViews { handle: r.handle, email: opts.includeEmails && email ? email : undefined, relatedRecords, - indexedAt: r.indexedAt, + indexedAt: r.indexedAt ?? createdAt, moderation: { currentAction: action ? { @@ -605,7 +606,7 @@ export class ModerationViews { } } -type RepoResult = DidHandle & RepoRoot +type RepoResult = DidHandle & OptionalJoin type ActionResult = Selectable diff --git a/packages/pds/tests/db.test.ts b/packages/pds/tests/db.test.ts index 1a2a42f0930..653e770331f 100644 --- a/packages/pds/tests/db.test.ts +++ b/packages/pds/tests/db.test.ts @@ -52,7 +52,6 @@ describe('db', () => { root: 'x', rev: 'x', indexedAt: 'bad-date', - takedownId: null, }) }) From 02062bc11b85e2119bf7aa0c3f18921753aa4a16 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 9 Oct 2023 14:47:24 -0400 Subject: [PATCH 012/135] cleanup ensuring on correct pds, add to uploadBlob --- packages/pds/src/api/com/atproto/repo/applyWrites.ts | 11 +++++------ packages/pds/src/api/com/atproto/repo/createRecord.ts | 11 +++++------ packages/pds/src/api/com/atproto/repo/deleteRecord.ts | 11 +++++------ packages/pds/src/api/com/atproto/repo/putRecord.ts | 11 +++++------ packages/pds/src/api/com/atproto/repo/uploadBlob.ts | 9 ++++++++- packages/pds/src/auth.ts | 2 +- 6 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/pds/src/api/com/atproto/repo/applyWrites.ts b/packages/pds/src/api/com/atproto/repo/applyWrites.ts index 7de0cef7b8f..868c6f682ed 100644 --- a/packages/pds/src/api/com/atproto/repo/applyWrites.ts +++ b/packages/pds/src/api/com/atproto/repo/applyWrites.ts @@ -62,16 +62,15 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const tx = input.body const { repo, validate, swapCommit } = tx - const account = await ctx.services.account(ctx.db).getAccount(repo) - if (!account) { + const did = await ctx.services.account(ctx.db).getDidForActor(repo) + + if (!did) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } - - const { did, pdsDid } = account - ensureThisPds(ctx, pdsDid) - 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 b76f5d3cb17..c59dd73d94b 100644 --- a/packages/pds/src/api/com/atproto/repo/createRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/createRecord.ts @@ -50,16 +50,15 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const { repo, collection, rkey, record, swapCommit, validate } = input.body - const account = await ctx.services.account(ctx.db).getAccount(repo) - if (!account) { + const did = await ctx.services.account(ctx.db).getDidForActor(repo) + + if (!did) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } - - const { did, pdsDid } = account - ensureThisPds(ctx, pdsDid) - 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 5424606e889..c49738c3710 100644 --- a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts @@ -37,15 +37,14 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const { repo, collection, rkey, swapCommit, swapRecord } = input.body - const account = await ctx.services.account(ctx.db).getAccount(repo) - if (!account) { + const did = await ctx.services.account(ctx.db).getDidForActor(repo) + + if (!did) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } - - const { did, pdsDid } = account - ensureThisPds(ctx, pdsDid) - if (did !== auth.credentials.did) { throw new AuthRequiredError() } diff --git a/packages/pds/src/api/com/atproto/repo/putRecord.ts b/packages/pds/src/api/com/atproto/repo/putRecord.ts index 0f827b6d908..35e6e57654d 100644 --- a/packages/pds/src/api/com/atproto/repo/putRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/putRecord.ts @@ -50,6 +50,8 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const { repo, collection, @@ -59,14 +61,11 @@ export default function (server: Server, ctx: AppContext) { swapCommit, swapRecord, } = input.body - const account = await ctx.services.account(ctx.db).getAccount(repo) - if (!account) { + const did = await ctx.services.account(ctx.db).getDidForActor(repo) + + if (!did) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } - - const { did, pdsDid } = account - ensureThisPds(ctx, pdsDid) - 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 4b722889a1c..99d95368b26 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -1,7 +1,12 @@ import { streamToBytes } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + ensureThisPds, + proxy, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.uploadBlob({ @@ -22,6 +27,8 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const requester = auth.credentials.did const blob = await ctx.services .repo(ctx.db) diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index ca9a884579a..47d20ff7635 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -259,7 +259,7 @@ export const accessVerifierCheckTakedown = ) } return { - credentials: creds, + credentials: { ...creds, pdsDid: actor.pdsDid }, artifacts: auth.getToken(ctx.req), } } From ab9dcb56bd4ff46701e332ab339b68d986621718 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 9 Oct 2023 14:55:24 -0400 Subject: [PATCH 013/135] feature branch diff --- packages/pds/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index cc9e1555895..0732b6efd7a 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -185,3 +185,5 @@ export class PDS { } export default PDS + +// @TODO remove me: just producing a diff to start a feature branch. From 5862d37163589429eaeffc2c19bc71b3cb351910 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 10 Oct 2023 22:35:02 -0400 Subject: [PATCH 014/135] start testing multi-pds flows, update dev-env mocks --- packages/dev-env/src/network-no-appview.ts | 2 +- packages/dev-env/src/network.ts | 2 +- packages/dev-env/src/util.ts | 48 ++++++---- packages/pds/src/config/config.ts | 3 +- packages/pds/src/services/account/index.ts | 6 +- packages/pds/tests/multi-pds/auth.test.ts | 104 +++++++++++++++++++++ 6 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 packages/pds/tests/multi-pds/auth.test.ts diff --git a/packages/dev-env/src/network-no-appview.ts b/packages/dev-env/src/network-no-appview.ts index 25054b2ab4e..1cf00782e64 100644 --- a/packages/dev-env/src/network-no-appview.ts +++ b/packages/dev-env/src/network-no-appview.ts @@ -28,7 +28,7 @@ export class TestNetworkNoAppView { ...params.pds, }) - mockNetworkUtilities(pds) + mockNetworkUtilities([pds]) return new TestNetworkNoAppView(plc, pds) } diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index a6c150f0353..1c4779c6574 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -54,7 +54,7 @@ export class TestNetwork extends TestNetworkNoAppView { ...params.pds, }) - mockNetworkUtilities(pds, bsky) + mockNetworkUtilities([pds], bsky) return new TestNetwork(plc, pds, bsky) } diff --git a/packages/dev-env/src/util.ts b/packages/dev-env/src/util.ts index 7e3f275ca98..c667f1120ae 100644 --- a/packages/dev-env/src/util.ts +++ b/packages/dev-env/src/util.ts @@ -2,15 +2,17 @@ import { IdResolver } from '@atproto/identity' import { TestPds } from './pds' import { TestBsky } from './bsky' -export const mockNetworkUtilities = (pds: TestPds, bsky?: TestBsky) => { - mockResolvers(pds.ctx.idResolver, pds) +export const mockNetworkUtilities = (pdses: TestPds[], bsky?: TestBsky) => { + for (const pds of pdses) { + mockResolvers(pds.ctx.idResolver, pdses) + } if (bsky) { - mockResolvers(bsky.ctx.idResolver, pds) - mockResolvers(bsky.indexer.ctx.idResolver, pds) + mockResolvers(bsky.ctx.idResolver, pdses) + mockResolvers(bsky.indexer.ctx.idResolver, pdses) } } -export const mockResolvers = (idResolver: IdResolver, pds: TestPds) => { +export const mockResolvers = (idResolver: IdResolver, pdses: TestPds[]) => { // Map pds public url to its local url when resolving from plc const origResolveDid = idResolver.did.resolveNoCache idResolver.did.resolveNoCache = async (did: string) => { @@ -20,29 +22,37 @@ export const mockResolvers = (idResolver: IdResolver, pds: TestPds) => { ) as ReturnType) const service = result?.service?.find((svc) => svc.id === '#atproto_pds') if (typeof service?.serviceEndpoint === 'string') { - service.serviceEndpoint = service.serviceEndpoint.replace( - pds.ctx.cfg.service.publicUrl, - `http://localhost:${pds.port}`, - ) + for (const pds of pdses) { + service.serviceEndpoint = service.serviceEndpoint.replace( + pds.ctx.cfg.service.publicUrl, + `http://localhost:${pds.port}`, + ) + } } return result } const origResolveHandleDns = idResolver.handle.resolveDns idResolver.handle.resolve = async (handle: string) => { - const isPdsHandle = pds.ctx.cfg.identity.serviceHandleDomains.some( - (domain) => handle.endsWith(domain), - ) - if (!isPdsHandle) { + const eligiblePdses = pdses.filter((pds) => { + return pds.ctx.cfg.identity.serviceHandleDomains.some((domain) => + handle.endsWith(domain), + ) + }) + + if (!eligiblePdses.length) { return origResolveHandleDns.call(idResolver.handle, handle) } - const url = `${pds.url}/.well-known/atproto-did` - try { - const res = await fetch(url, { headers: { host: handle } }) - return await res.text() - } catch (err) { - return undefined + for (const pds of eligiblePdses) { + const url = `${pds.url}/.well-known/atproto-did` + try { + const res = await fetch(url, { headers: { host: handle } }) + if (res.status !== 200) continue + return await res.text() + } catch { + // ignore + } } } } diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 68d043e6431..995314ec190 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -13,7 +13,8 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { hostname === 'localhost' ? `http://localhost:${port}` : `https://${hostname}` - const did = env.serviceDid ?? `did:web:${hostname}` + const publicHostname = new URL(publicUrl).host + const did = env.serviceDid ?? `did:web:${encodeURIComponent(publicHostname)}` const serviceCfg: ServerConfig['service'] = { port, hostname, diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 98b124b1b8a..6b108a7987d 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -50,10 +50,10 @@ export class AccountService { ) } }) - .select(['pds.did as pdsDid']) - .selectAll('user_account') + .selectAll('repo_root') // first so that its possibly-null vals don't shadow other cols .selectAll('did_handle') - .selectAll('repo_root') + .selectAll('user_account') + .select('pds.did as pdsDid') .executeTakeFirst() return result || null } diff --git a/packages/pds/tests/multi-pds/auth.test.ts b/packages/pds/tests/multi-pds/auth.test.ts new file mode 100644 index 00000000000..6c03ccb28bc --- /dev/null +++ b/packages/pds/tests/multi-pds/auth.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert' +import * as ui8 from 'uint8arrays' +import AtpAgent from '@atproto/api' +import { Secp256k1Keypair } from '@atproto/crypto' +import { TestPds, TestPlc, mockNetworkUtilities } from '@atproto/dev-env' + +describe('multi-pds auth', () => { + let plc: TestPlc + let entryway: TestPds + let entrywayAgent: AtpAgent + let pds: TestPds + let pdsAgent: AtpAgent + + beforeAll(async () => { + const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) + const jwtSigningPriv = ui8.toString(await jwtSigningKey.export(), 'hex') + const plcRotationKey = await Secp256k1Keypair.create({ exportable: true }) + const plcRotationPriv = ui8.toString(await plcRotationKey.export(), 'hex') + const recoveryKey = (await Secp256k1Keypair.create()).did() + plc = await TestPlc.create({}) + entryway = await TestPds.create({ + dbPostgresUrl: process.env.DB_POSTGRES_URL, + dbPostgresSchema: 'multi_pds_account_entryway', + didPlcUrl: plc.url, + recoveryDidKey: recoveryKey, + jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, + plcRotationKeyK256PrivateKeyHex: plcRotationPriv, + }) + pds = await TestPds.create({ + dbPostgresUrl: process.env.DB_POSTGRES_URL, + dbPostgresSchema: 'multi_pds_account_pds', + didPlcUrl: plc.url, + recoveryDidKey: recoveryKey, + jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, + plcRotationKeyK256PrivateKeyHex: plcRotationPriv, + }) + await entryway.ctx.db.db + .insertInto('pds') + .values({ + did: pds.ctx.cfg.service.did, + host: new URL(pds.ctx.cfg.service.publicUrl).host, + }) + .execute() + mockNetworkUtilities([entryway, pds]) + entrywayAgent = entryway.getClient() + pdsAgent = pds.getClient() + }) + + afterAll(async () => { + await plc.close() + await entryway.close() + await pds.close() + }) + + it('assigns user to a pds.', async () => { + const { + data: { did }, + } = await entrywayAgent.api.com.atproto.server.createAccount({ + email: 'alice@test.com', + handle: 'alice.test', + password: 'test123', + }) + + // @TODO move these steps into account creation process + await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) + await plc + .getClient() + .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) + await plc + .getClient() + .updateAtprotoKey( + did, + pds.ctx.plcRotationKey, + pds.ctx.repoSigningKey.did(), + ) + + await pdsAgent.api.com.atproto.server.createAccount({ + did: did, + email: 'alice@test.com', + handle: 'alice.test', + password: 'test123', + }) + + const entrywayAccount = await entryway.ctx.services + .account(entryway.ctx.db) + .getAccount(did) + assert(entrywayAccount) + + expect(entrywayAccount.did).toBe(did) + expect(entrywayAccount.pdsId).not.toBe(null) + expect(entrywayAccount.pdsDid).toBe(pds.ctx.cfg.service.did) + expect(entrywayAccount.root).toBe(null) + + const pdsAccount = await pds.ctx.services + .account(pds.ctx.db) + .getAccount(did) + assert(pdsAccount) + + expect(pdsAccount.did).toBe(did) + expect(pdsAccount.pdsId).toBe(null) + expect(pdsAccount.pdsDid).toBe(null) + expect(pdsAccount.root).not.toBe(null) + }) +}) From 8c7e5fe3fcb0ebf0f8069f1e3d8b32a5cb9785fb Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 11 Oct 2023 01:08:54 -0400 Subject: [PATCH 015/135] misc fixes, tests --- .../src/api/com/atproto/repo/uploadBlob.ts | 5 +- packages/pds/src/api/proxy.ts | 6 +- packages/pds/tests/multi-pds/auth.test.ts | 151 +++++++++++++++++- 3 files changed, 155 insertions(+), 7 deletions(-) diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index 99d95368b26..f690b90b51f 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -18,7 +18,10 @@ export default function (server: Server, ctx: AppContext) { async (agent) => { const result = await agent.api.com.atproto.repo.uploadBlob( await streamToBytes(input.body), // @TODO proxy streaming - authPassthru(req, true), + { + ...authPassthru(req), + encoding: input.encoding, + }, ) return resultPassthru(result) }, diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 9d46637e5e8..b80f2b03778 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -18,7 +18,11 @@ export const proxy = async ( throw new UpstreamFailureError('unknown pds') } // @TODO reuse agents - const agent = new AtpAgent({ service: `https://${pds.host}` }) + const service = new URL(`https://${pds.host}`) + if (service.hostname === 'localhost') { + service.protocol = 'http:' + } + const agent = new AtpAgent({ service }) try { return await fn(agent) } catch (err) { diff --git a/packages/pds/tests/multi-pds/auth.test.ts b/packages/pds/tests/multi-pds/auth.test.ts index 6c03ccb28bc..68c70938be1 100644 --- a/packages/pds/tests/multi-pds/auth.test.ts +++ b/packages/pds/tests/multi-pds/auth.test.ts @@ -1,8 +1,15 @@ import assert from 'node:assert' +import fs from 'node:fs/promises' import * as ui8 from 'uint8arrays' -import AtpAgent from '@atproto/api' +import AtpAgent, { AtUri } from '@atproto/api' import { Secp256k1Keypair } from '@atproto/crypto' -import { TestPds, TestPlc, mockNetworkUtilities } from '@atproto/dev-env' +import { + SeedClient, + TestPds, + TestPlc, + mockNetworkUtilities, +} from '@atproto/dev-env' +import { ids } from '@atproto/api/src/client/lexicons' describe('multi-pds auth', () => { let plc: TestPlc @@ -10,6 +17,8 @@ describe('multi-pds auth', () => { let entrywayAgent: AtpAgent let pds: TestPds let pdsAgent: AtpAgent + let alice: string + let accessToken: string beforeAll(async () => { const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) @@ -74,8 +83,9 @@ describe('multi-pds auth', () => { pds.ctx.repoSigningKey.did(), ) + alice = did await pdsAgent.api.com.atproto.server.createAccount({ - did: did, + did, email: 'alice@test.com', handle: 'alice.test', password: 'test123', @@ -85,7 +95,6 @@ describe('multi-pds auth', () => { .account(entryway.ctx.db) .getAccount(did) assert(entrywayAccount) - expect(entrywayAccount.did).toBe(did) expect(entrywayAccount.pdsId).not.toBe(null) expect(entrywayAccount.pdsDid).toBe(pds.ctx.cfg.service.did) @@ -95,10 +104,142 @@ describe('multi-pds auth', () => { .account(pds.ctx.db) .getAccount(did) assert(pdsAccount) - expect(pdsAccount.did).toBe(did) expect(pdsAccount.pdsId).toBe(null) expect(pdsAccount.pdsDid).toBe(null) expect(pdsAccount.root).not.toBe(null) }) + + it('creates a session that auths across services.', async () => { + const { data: session } = + await entrywayAgent.api.com.atproto.server.createSession({ + identifier: alice, + password: 'test123', + }) + accessToken = session.accessJwt + const { data: entrywayResult } = + await entrywayAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + const { data: pdsResult } = + await pdsAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + expect(entrywayResult.did).toBe(alice) + expect(pdsResult.did).toBe(alice) + }) + + describe('entryway', () => { + it('proxies writes to pds.', async () => { + const { data: profileRef } = + await entrywayAgent.com.atproto.repo.createRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice' }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: profile } = await pdsAgent.com.atproto.repo.getRecord({ + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profile.cid).toBe(profileRef.cid) + const { data: profileRefUpdated } = + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice!' }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: profileUpdated } = + await pdsAgent.com.atproto.repo.getRecord({ + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profileUpdated.cid).toBe(profileRefUpdated.cid) + await entrywayAgent.com.atproto.repo.deleteRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const tryGetProfile = pdsAgent.com.atproto.repo.getRecord({ + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + await expect(tryGetProfile).rejects.toThrow('Could not locate record') + }) + + it('proxies blob uploads to pds.', async () => { + const file = await fs.readFile('tests/sample-img/key-portrait-small.jpg') + const { + data: { blob }, + } = await entrywayAgent.api.com.atproto.repo.uploadBlob(file, { + encoding: 'image/jpeg', + headers: SeedClient.getHeaders(accessToken), + }) + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice', avatar: blob }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: bytes } = await pdsAgent.com.atproto.sync.getBlob({ + did: alice, + cid: blob.ref.toString(), + }) + expect(Buffer.compare(file, bytes)).toBe(0) + }) + + it('proxies repo reads to pds.', async () => { + const { data: profileRef } = + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice' }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: results } = + await entrywayAgent.com.atproto.repo.listRecords({ + repo: alice, + collection: ids.AppBskyActorProfile, + }) + expect(results.records.map((record) => record.uri)).toContain( + profileRef.uri, + ) + }) + }) }) From bf3dc059ccb8f519d5184cb4096c5d874a39c2c2 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 11 Oct 2023 11:18:28 -0400 Subject: [PATCH 016/135] test pds entryway auth w/o priv keys on both servers --- packages/pds/src/auth.ts | 23 ++++++++++++++++++++--- packages/pds/src/config/env.ts | 4 ++++ packages/pds/src/config/secrets.ts | 14 ++++++++++++++ packages/pds/src/context.ts | 1 + packages/pds/tests/multi-pds/auth.test.ts | 9 +++++++-- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 47d20ff7635..047068dd9b8 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -1,5 +1,10 @@ import * as assert from 'node:assert' -import { KeyObject, createPrivateKey, createSecretKey } from 'node:crypto' +import { + KeyObject, + createPrivateKey, + createPublicKey, + createSecretKey, +} from 'node:crypto' import express from 'express' import KeyEncoder from 'key-encoder' import * as ui8 from 'uint8arrays' @@ -17,6 +22,7 @@ const HMACSHA256_JWT = 'HS256' export type ServerAuthOpts = { jwtSecret: string jwtSigningKey?: crypto.Secp256k1Keypair + jwtVerifyKeyHex?: string adminPass: string moderatorPass?: string triagePass?: string @@ -41,6 +47,7 @@ export type RefreshToken = AuthToken & { jti: string; aud: string } export class ServerAuth { private _signingSecret: KeyObject private _signingKeyPromise?: Promise + private _verifyKeyPromise?: Promise private _adminPass: string private _moderatorPass?: string private _triagePass?: string @@ -49,6 +56,9 @@ export class ServerAuth { this._signingSecret = createSecretKey(Buffer.from(opts.jwtSecret)) this._signingKeyPromise = opts.jwtSigningKey && createPrivateKeyObject(opts.jwtSigningKey) + this._verifyKeyPromise = opts.jwtVerifyKeyHex + ? createPublicKeyObject(opts.jwtVerifyKeyHex) + : this._signingKeyPromise this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass this._triagePass = opts.triagePass @@ -178,8 +188,8 @@ export class ServerAuth { const header = jose.decodeProtectedHeader(token) let result: jose.JWTVerifyResult try { - if (header.alg === SECP256K1_JWT && this._signingKeyPromise) { - const key = await this._signingKeyPromise + if (header.alg === SECP256K1_JWT && this._verifyKeyPromise) { + const key = await this._verifyKeyPromise result = await jose.jwtVerify(token, key, options) } else { const key = this._signingSecret @@ -370,4 +380,11 @@ const createPrivateKeyObject = async ( return createPrivateKey({ format: 'pem', key }) } +const createPublicKeyObject = async ( + publicKeyHex: string, +): Promise => { + const key = keyEncoder.encodePublic(publicKeyHex, 'raw', 'pem') + return createPublicKey({ format: 'pem', key }) +} + const keyEncoder = new KeyEncoder('secp256k1') diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 1e3d84194b4..2364aa6dc6a 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -77,6 +77,9 @@ export const readEnv = (): ServerEnvironment => { jwtSigningKeyK256PrivateKeyHex: envStr( 'PDS_JWT_SIGNING_KEY_K256_PRIVATE_KEY_HEX', ), + jwtVerifyKeyK256PublicKeyHex: envStr( + 'PDS_JWT_VERIFY_KEY_K256_PUBLIC_KEY_HEX', + ), adminPassword: envStr('PDS_ADMIN_PASSWORD'), moderatorPassword: envStr('PDS_MODERATOR_PASSWORD'), triagePassword: envStr('PDS_TRIAGE_PASSWORD'), @@ -167,6 +170,7 @@ export type ServerEnvironment = { // secrets jwtSecret?: string jwtSigningKeyK256PrivateKeyHex?: string + jwtVerifyKeyK256PublicKeyHex?: string adminPassword?: string moderatorPassword?: string triagePassword?: string diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index 12acf2c132b..3a9c8b57364 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -42,6 +42,13 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { privateKeyHex: env.jwtSigningKeyK256PrivateKeyHex, } } + let jwtVerifyKey: ServerSecrets['jwtVerifyKey'] + if (env.jwtVerifyKeyK256PublicKeyHex) { + jwtVerifyKey = { + provider: 'memory', + publicKeyHex: env.jwtVerifyKeyK256PublicKeyHex, + } + } if (!env.jwtSecret) { throw new Error('Must provide a JWT secret') @@ -54,6 +61,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { return { jwtSecret: env.jwtSecret, jwtSigningKey, + jwtVerifyKey, adminPassword: env.adminPassword, moderatorPassword: env.moderatorPassword ?? env.adminPassword, triagePassword: @@ -66,6 +74,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { export type ServerSecrets = { jwtSecret: string jwtSigningKey?: SigningKeyMemory + jwtVerifyKey?: VerifyKeyMemory adminPassword: string moderatorPassword: string triagePassword: string @@ -82,3 +91,8 @@ export type SigningKeyMemory = { provider: 'memory' privateKeyHex: string } + +export type VerifyKeyMemory = { + provider: 'memory' + publicKeyHex: string +} diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index fd3b30ff363..50a620d634a 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -161,6 +161,7 @@ export class AppContext { const auth = new ServerAuth({ jwtSigningKey, + jwtVerifyKeyHex: secrets.jwtVerifyKey?.publicKeyHex, jwtSecret: secrets.jwtSecret, adminPass: secrets.adminPassword, moderatorPass: secrets.moderatorPassword, diff --git a/packages/pds/tests/multi-pds/auth.test.ts b/packages/pds/tests/multi-pds/auth.test.ts index 68c70938be1..6d8cbf9b483 100644 --- a/packages/pds/tests/multi-pds/auth.test.ts +++ b/packages/pds/tests/multi-pds/auth.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert' import fs from 'node:fs/promises' import * as ui8 from 'uint8arrays' -import AtpAgent, { AtUri } from '@atproto/api' +import * as jose from 'jose' +import AtpAgent from '@atproto/api' import { Secp256k1Keypair } from '@atproto/crypto' import { SeedClient, @@ -23,6 +24,7 @@ describe('multi-pds auth', () => { beforeAll(async () => { const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) const jwtSigningPriv = ui8.toString(await jwtSigningKey.export(), 'hex') + const jwtVerifyPub = jwtSigningKey.publicKeyStr('hex') const plcRotationKey = await Secp256k1Keypair.create({ exportable: true }) const plcRotationPriv = ui8.toString(await plcRotationKey.export(), 'hex') const recoveryKey = (await Secp256k1Keypair.create()).did() @@ -40,7 +42,8 @@ describe('multi-pds auth', () => { dbPostgresSchema: 'multi_pds_account_pds', didPlcUrl: plc.url, recoveryDidKey: recoveryKey, - jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, + jwtVerifyKeyK256PublicKeyHex: jwtVerifyPub, + jwtSigningKeyK256PrivateKeyHex: undefined, // no private key material on pds for jwts plcRotationKeyK256PrivateKeyHex: plcRotationPriv, }) await entryway.ctx.db.db @@ -117,6 +120,8 @@ describe('multi-pds auth', () => { password: 'test123', }) accessToken = session.accessJwt + const tokenHeader = jose.decodeProtectedHeader(accessToken) + expect(tokenHeader.alg).toBe('ES256K') // asymmetric, from the jwt key and not the secret const { data: entrywayResult } = await entrywayAgent.api.com.atproto.server.getSession( {}, From 5f4882e15eac86240edbb23d9e4c13460414e8ce Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 11 Oct 2023 12:42:32 -0400 Subject: [PATCH 017/135] weighted pds assignment --- .../api/com/atproto/server/createAccount.ts | 18 ++++++++++++++---- .../20231004T040354739Z-user-account-pds.ts | 1 + packages/pds/src/db/tables/pds.ts | 3 ++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index dfedd45ae9f..d58c4522cf2 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,4 +1,3 @@ -import { randomInt } from 'node:crypto' import { InvalidRequestError } from '@atproto/xrpc-server' import disposable from 'disposable-email' import * as plc from '@did-plc/lib' @@ -263,7 +262,18 @@ const getDidAndPlcOp = async ( // @TODO this implementation is a stub const assignPds = async (ctx: AppContext) => { const pdses = await ctx.db.db.selectFrom('pds').selectAll().execute() - if (!pdses.length) return - const pds = pdses.at(randomInt(pdses.length)) - return pds + const idx = randomIndexByWeight(pdses.map((pds) => pds.weight)) + if (idx === -1) return + return pdses.at(idx) +} + +const randomIndexByWeight = (weights) => { + let sum = 0 + const cumulative = weights.map((weight) => { + sum += weight + return sum + }) + if (!sum) return -1 + const rand = Math.random() * sum + return cumulative.findIndex((item) => item >= rand) } diff --git a/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts index c5ae5c86067..4006c431028 100644 --- a/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts +++ b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts @@ -13,6 +13,7 @@ export async function up(db: Kysely, dialect: Dialect): Promise { await pdsBuilder .addColumn('did', 'varchar', (col) => col.notNull()) .addColumn('host', 'varchar', (col) => col.notNull()) + .addColumn('weight', 'integer', (col) => col.notNull().defaultTo(1)) .execute() await db.schema .alterTable('user_account') diff --git a/packages/pds/src/db/tables/pds.ts b/packages/pds/src/db/tables/pds.ts index b2f102366cc..ec0b9f3f156 100644 --- a/packages/pds/src/db/tables/pds.ts +++ b/packages/pds/src/db/tables/pds.ts @@ -1,9 +1,10 @@ -import { GeneratedAlways } from 'kysely' +import { Generated, GeneratedAlways } from 'kysely' export interface Pds { id: GeneratedAlways did: string host: string + weight: Generated } export const tableName = 'pds' From ad093f47805d3f2186db15f6309353367594696e Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 11 Oct 2023 12:44:46 -0400 Subject: [PATCH 018/135] test auth flow when switching pdses --- packages/pds/tests/multi-pds/auth.test.ts | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/pds/tests/multi-pds/auth.test.ts b/packages/pds/tests/multi-pds/auth.test.ts index 6d8cbf9b483..80eb3931ec3 100644 --- a/packages/pds/tests/multi-pds/auth.test.ts +++ b/packages/pds/tests/multi-pds/auth.test.ts @@ -246,5 +246,101 @@ describe('multi-pds auth', () => { profileRef.uri, ) }) + + it('initiates token refresh when account moves pdses, issues updated credentials.', async () => { + // don't assign bob to separate pds + await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() + const { + data: { did, ...initialSession }, + } = await entrywayAgent.api.com.atproto.server.createAccount({ + email: 'bob@test.com', + handle: 'bob.test', + password: 'test123', + }) + // use initial session credentials for a write + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Bob' }, + }, + { + headers: SeedClient.getHeaders(initialSession.accessJwt), + encoding: 'application/json', + }, + ) + // now move bob to a separate pds + await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) + await plc + .getClient() + .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) + await plc + .getClient() + .updateAtprotoKey( + did, + pds.ctx.plcRotationKey, + pds.ctx.repoSigningKey.did(), + ) + const { id: pdsId } = await entryway.ctx.db.db + .updateTable('pds') + .set({ weight: 1 }) + .returningAll() + .executeTakeFirstOrThrow() + await entryway.ctx.db.db + .updateTable('user_account') + .set({ pdsId }) + .where('did', '=', did) + .returningAll() + .executeTakeFirst() + await pdsAgent.api.com.atproto.server.createAccount({ + did, + email: 'bob@test.com', + handle: 'bob.test', + password: 'test123', + }) + // attempt a write again on bob's original pds with same creds + const tryPutRecord = entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Bob!' }, + }, + { + headers: SeedClient.getHeaders(initialSession.accessJwt), + encoding: 'application/json', + }, + ) + await expect(tryPutRecord).rejects.toThrow( + 'Token audience is out of date', + ) + const err = await tryPutRecord.catch((err) => err) + expect(err.status).toBe(400) + expect(err.error).toBe('ExpiredToken') + // refresh session and try again + const { data: session } = + await entrywayAgent.com.atproto.server.refreshSession(undefined, { + headers: SeedClient.getHeaders(initialSession.refreshJwt), + }) + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Bob!' }, + }, + { + headers: SeedClient.getHeaders(session.accessJwt), + encoding: 'application/json', + }, + ) + const { data: profile } = await pdsAgent.com.atproto.repo.getRecord({ + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profile.value['displayName']).toEqual('Bob!') + }) }) }) From 8b6f61a5ac2fddc562563c7356d989c1d2bc6fa4 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 12 Oct 2023 00:55:44 -0400 Subject: [PATCH 019/135] tidy --- packages/pds/tests/multi-pds/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/tests/multi-pds/auth.test.ts b/packages/pds/tests/multi-pds/auth.test.ts index 80eb3931ec3..bce20d2ac9f 100644 --- a/packages/pds/tests/multi-pds/auth.test.ts +++ b/packages/pds/tests/multi-pds/auth.test.ts @@ -247,7 +247,7 @@ describe('multi-pds auth', () => { ) }) - it('initiates token refresh when account moves pdses, issues updated credentials.', async () => { + it('initiates token refresh when account moves off of entryway.', async () => { // don't assign bob to separate pds await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() const { From 52022c06e20500f45c3974959ff2f6d3e511ba78 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 12 Oct 2023 17:20:25 -0400 Subject: [PATCH 020/135] configure pds as non-entryway, validate aud on non-entry, test --- packages/pds/src/api/proxy.ts | 25 ++++++--- packages/pds/src/auth.ts | 38 ++++++++++--- packages/pds/src/config/config.ts | 2 + packages/pds/src/config/env.ts | 2 + packages/pds/src/context.ts | 8 +-- packages/pds/tests/multi-pds/auth.test.ts | 67 +++++++++++++++++++++-- 6 files changed, 116 insertions(+), 26 deletions(-) diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index b80f2b03778..573c4abea49 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -1,7 +1,11 @@ import * as express from 'express' import AtpAgent from '@atproto/api' import { Headers, XRPCError } from '@atproto/xrpc' -import { InvalidRequestError, UpstreamFailureError } from '@atproto/xrpc-server' +import { + AuthRequiredError, + InvalidRequestError, + UpstreamFailureError, +} from '@atproto/xrpc-server' import AppContext from '../context' export const proxy = async ( @@ -9,7 +13,7 @@ export const proxy = async ( pdsDid: string | null | undefined, fn: (agent: AtpAgent) => Promise, ): Promise => { - if (isThisPds(ctx, pdsDid)) { + if (isThisPds(ctx, pdsDid) || !ctx.cfg.service.isEntryway) { return null // skip proxying } const accountService = ctx.services.account(ctx.db) @@ -50,14 +54,19 @@ export const isThisPds = ( } // @NOTE on the identity service this serves a 400 w/ ExpiredToken to prompt a refresh flow from the client. -// but on our other PDSes the same case should be a 403 w/ AccountNotFound, assuming their access token verifies. +// the analogous case on our non-entryway PDSes would be a 403 w/ AccountNotFound when the user can auth but doesn't have an account. export const ensureThisPds = (ctx: AppContext, pdsDid: string | null) => { if (!isThisPds(ctx, pdsDid)) { - // instruct client to refresh token during potential account migration - throw new InvalidRequestError( - 'Token audience is out of date', - 'ExpiredToken', - ) + if (ctx.cfg.service.isEntryway) { + // instruct client to refresh token during potential account migration + throw new InvalidRequestError( + 'Token audience is out of date', + 'ExpiredToken', + ) + } else { + // this shouldn't really happen, since we validate the token's audience on our non-entryway PDSes. + throw new AuthRequiredError('Bad token audience') + } } } diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 047068dd9b8..3409c0ec902 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -10,7 +10,11 @@ import KeyEncoder from 'key-encoder' import * as ui8 from 'uint8arrays' import * as jose from 'jose' import * as crypto from '@atproto/crypto' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' +import { + AuthRequiredError, + ForbiddenError, + InvalidRequestError, +} from '@atproto/xrpc-server' import AppContext from './context' import { softDeleted } from './db/util' @@ -232,12 +236,15 @@ export const parseBasicAuth = ( } export const accessVerifier = - (auth: ServerAuth) => + (auth: ServerAuth, { cfg }: AppContext) => async (ctx: { req: express.Request; res: express.Response }) => { const creds = await auth.getCredentialsOrThrow(ctx.req, [ AuthScope.Access, AuthScope.AppPass, ]) + if (!cfg.service.isEntryway && creds.audience !== cfg.service.did) { + throw new AuthRequiredError('Bad token audience') + } return { credentials: creds, artifacts: auth.getToken(ctx.req), @@ -245,9 +252,12 @@ export const accessVerifier = } export const accessVerifierNotAppPassword = - (auth: ServerAuth) => + (auth: ServerAuth, { cfg }: AppContext) => async (ctx: { req: express.Request; res: express.Response }) => { const creds = await auth.getCredentialsOrThrow(ctx.req, [AuthScope.Access]) + if (!cfg.service.isEntryway && creds.audience !== cfg.service.did) { + throw new AuthRequiredError('Bad token audience') + } return { credentials: creds, artifacts: auth.getToken(ctx.req), @@ -255,14 +265,21 @@ export const accessVerifierNotAppPassword = } export const accessVerifierCheckTakedown = - (auth: ServerAuth, { db, services }: AppContext) => + (auth: ServerAuth, { db, services, cfg }: AppContext) => async (ctx: { req: express.Request; res: express.Response }) => { const creds = await auth.getCredentialsOrThrow(ctx.req, [ AuthScope.Access, AuthScope.AppPass, ]) + if (!cfg.service.isEntryway && creds.audience !== cfg.service.did) { + throw new AuthRequiredError('Bad token audience') + } const actor = await services.account(db).getAccount(creds.did, true) - if (!actor || softDeleted(actor)) { + if (!actor) { + // will be turned into ExpiredToken for the client if proxied by entryway + throw new ForbiddenError('Account not found', 'AccountNotFound') + } + if (softDeleted(actor)) { throw new AuthRequiredError( 'Account has been taken down', 'AccountTakedown', @@ -274,8 +291,8 @@ export const accessVerifierCheckTakedown = } } -export const accessOrRoleVerifier = (auth: ServerAuth) => { - const verifyAccess = accessVerifier(auth) +export const accessOrRoleVerifier = (auth: ServerAuth, ctx: AppContext) => { + const verifyAccess = accessVerifier(auth, ctx) const verifyRole = roleVerifier(auth) return async (ctx: { req: express.Request; res: express.Response }) => { // For non-admin tokens, we don't want to consider alternative verifiers and let it fail if it fails @@ -301,8 +318,11 @@ export const accessOrRoleVerifier = (auth: ServerAuth) => { } } -export const optionalAccessOrRoleVerifier = (auth: ServerAuth) => { - const verifyAccess = accessVerifier(auth) +export const optionalAccessOrRoleVerifier = ( + auth: ServerAuth, + ctx: AppContext, +) => { + const verifyAccess = accessVerifier(auth, ctx) return async (ctx: { req: express.Request; res: express.Response }) => { try { return await verifyAccess(ctx) diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 995314ec190..efa769ebc7a 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -20,6 +20,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { hostname, publicUrl, did, + isEntryway: env.isEntryway !== false, // defaults true version: env.version, // default? privacyPolicyUrl: env.privacyPolicyUrl, termsOfServiceUrl: env.termsOfServiceUrl, @@ -211,6 +212,7 @@ export type ServiceConfig = { hostname: string publicUrl: string did: string + isEntryway: boolean version?: string privacyPolicyUrl?: string termsOfServiceUrl?: string diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 2364aa6dc6a..731a7ea22f7 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -7,6 +7,7 @@ export const readEnv = (): ServerEnvironment => { hostname: envStr('PDS_HOSTNAME'), serviceDid: envStr('PDS_SERVICE_DID'), version: envStr('PDS_VERSION'), + isEntryway: envBool('PDS_IS_ENTRYWAY'), privacyPolicyUrl: envStr('PDS_PRIVACY_POLICY_URL'), termsOfServiceUrl: envStr('PDS_TERMS_OF_SERVICE_URL'), @@ -106,6 +107,7 @@ export type ServerEnvironment = { hostname?: string serviceDid?: string version?: string + isEntryway?: boolean privacyPolicyUrl?: string termsOfServiceUrl?: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 50a620d634a..19cad378b4c 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -222,11 +222,11 @@ export class AppContext { } get accessVerifier() { - return auth.accessVerifier(this.auth) + return auth.accessVerifier(this.auth, this) } get accessVerifierNotAppPassword() { - return auth.accessVerifierNotAppPassword(this.auth) + return auth.accessVerifierNotAppPassword(this.auth, this) } get accessVerifierCheckTakedown() { @@ -242,11 +242,11 @@ export class AppContext { } get accessOrRoleVerifier() { - return auth.accessOrRoleVerifier(this.auth) + return auth.accessOrRoleVerifier(this.auth, this) } get optionalAccessOrRoleVerifier() { - return auth.optionalAccessOrRoleVerifier(this.auth) + return auth.optionalAccessOrRoleVerifier(this.auth, this) } async serviceAuthHeaders(did: string, audience?: string) { diff --git a/packages/pds/tests/multi-pds/auth.test.ts b/packages/pds/tests/multi-pds/auth.test.ts index bce20d2ac9f..492b5d75569 100644 --- a/packages/pds/tests/multi-pds/auth.test.ts +++ b/packages/pds/tests/multi-pds/auth.test.ts @@ -38,6 +38,7 @@ describe('multi-pds auth', () => { plcRotationKeyK256PrivateKeyHex: plcRotationPriv, }) pds = await TestPds.create({ + isEntryway: false, dbPostgresUrl: process.env.DB_POSTGRES_URL, dbPostgresSchema: 'multi_pds_account_pds', didPlcUrl: plc.url, @@ -51,6 +52,7 @@ describe('multi-pds auth', () => { .values({ did: pds.ctx.cfg.service.did, host: new URL(pds.ctx.cfg.service.publicUrl).host, + weight: 0, }) .execute() mockNetworkUtilities([entryway, pds]) @@ -65,6 +67,7 @@ describe('multi-pds auth', () => { }) it('assigns user to a pds.', async () => { + await entryway.ctx.db.db.updateTable('pds').set({ weight: 1 }).execute() const { data: { did }, } = await entrywayAgent.api.com.atproto.server.createAccount({ @@ -72,6 +75,7 @@ describe('multi-pds auth', () => { handle: 'alice.test', password: 'test123', }) + await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() // @TODO move these steps into account creation process await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) @@ -248,8 +252,6 @@ describe('multi-pds auth', () => { }) it('initiates token refresh when account moves off of entryway.', async () => { - // don't assign bob to separate pds - await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() const { data: { did, ...initialSession }, } = await entrywayAgent.api.com.atproto.server.createAccount({ @@ -283,9 +285,8 @@ describe('multi-pds auth', () => { pds.ctx.repoSigningKey.did(), ) const { id: pdsId } = await entryway.ctx.db.db - .updateTable('pds') - .set({ weight: 1 }) - .returningAll() + .selectFrom('pds') + .selectAll() .executeTakeFirstOrThrow() await entryway.ctx.db.db .updateTable('user_account') @@ -342,5 +343,61 @@ describe('multi-pds auth', () => { }) expect(profile.value['displayName']).toEqual('Bob!') }) + + it('initiates token refresh when account moves off of pds.', async () => { + const { + data: { did, ...initialSession }, + } = await entrywayAgent.api.com.atproto.server.createAccount({ + email: 'carol@test.com', + handle: 'carol.test', + password: 'test123', + }) + const outdatedAccessToken = await entryway.ctx.auth.createAccessToken({ + did, + pdsDid: pds.ctx.cfg.service.did, // pretending that carol was previously on this pds + }) + // attempt a write again on carol's previous pds with same creds + const tryPutRecord = entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Carol!' }, + }, + { + headers: SeedClient.getHeaders(outdatedAccessToken), + encoding: 'application/json', + }, + ) + await expect(tryPutRecord).rejects.toThrow( + 'Token audience is out of date', + ) + const err = await tryPutRecord.catch((err) => err) + expect(err.status).toBe(400) + expect(err.error).toBe('ExpiredToken') + // refresh session and try again + const { data: session } = + await entrywayAgent.com.atproto.server.refreshSession(undefined, { + headers: SeedClient.getHeaders(initialSession.refreshJwt), + }) + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Carol!' }, + }, + { + headers: SeedClient.getHeaders(session.accessJwt), + encoding: 'application/json', + }, + ) + const { data: profile } = await entrywayAgent.com.atproto.repo.getRecord({ + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profile.value['displayName']).toEqual('Carol!') + }) }) }) From 21197c2a65cde8edfd0a6bc282c15b0ea93fead2 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 12 Oct 2023 17:23:28 -0400 Subject: [PATCH 021/135] tidy tests --- packages/pds/tests/entryway.test.ts | 394 +++++++++++++++++++++ packages/pds/tests/multi-pds/auth.test.ts | 403 ---------------------- 2 files changed, 394 insertions(+), 403 deletions(-) create mode 100644 packages/pds/tests/entryway.test.ts delete mode 100644 packages/pds/tests/multi-pds/auth.test.ts diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts new file mode 100644 index 00000000000..30672971b52 --- /dev/null +++ b/packages/pds/tests/entryway.test.ts @@ -0,0 +1,394 @@ +import assert from 'node:assert' +import fs from 'node:fs/promises' +import * as ui8 from 'uint8arrays' +import * as jose from 'jose' +import AtpAgent from '@atproto/api' +import { Secp256k1Keypair } from '@atproto/crypto' +import { + SeedClient, + TestPds, + TestPlc, + mockNetworkUtilities, +} from '@atproto/dev-env' +import { ids } from '@atproto/api/src/client/lexicons' + +describe('entryway', () => { + let plc: TestPlc + let entryway: TestPds + let entrywayAgent: AtpAgent + let pds: TestPds + let pdsAgent: AtpAgent + let alice: string + let accessToken: string + + beforeAll(async () => { + const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) + const jwtSigningPriv = ui8.toString(await jwtSigningKey.export(), 'hex') + const jwtVerifyPub = jwtSigningKey.publicKeyStr('hex') + const plcRotationKey = await Secp256k1Keypair.create({ exportable: true }) + const plcRotationPriv = ui8.toString(await plcRotationKey.export(), 'hex') + const recoveryKey = (await Secp256k1Keypair.create()).did() + plc = await TestPlc.create({}) + entryway = await TestPds.create({ + dbPostgresUrl: process.env.DB_POSTGRES_URL, + dbPostgresSchema: 'multi_pds_account_entryway', + didPlcUrl: plc.url, + recoveryDidKey: recoveryKey, + jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, + plcRotationKeyK256PrivateKeyHex: plcRotationPriv, + }) + pds = await TestPds.create({ + isEntryway: false, + dbPostgresUrl: process.env.DB_POSTGRES_URL, + dbPostgresSchema: 'multi_pds_account_pds', + didPlcUrl: plc.url, + recoveryDidKey: recoveryKey, + jwtVerifyKeyK256PublicKeyHex: jwtVerifyPub, + jwtSigningKeyK256PrivateKeyHex: undefined, // no private key material on pds for jwts + plcRotationKeyK256PrivateKeyHex: plcRotationPriv, + }) + 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() + mockNetworkUtilities([entryway, pds]) + entrywayAgent = entryway.getClient() + pdsAgent = pds.getClient() + }) + + afterAll(async () => { + await plc.close() + await entryway.close() + await pds.close() + }) + + it('assigns user to a pds.', async () => { + await entryway.ctx.db.db.updateTable('pds').set({ weight: 1 }).execute() + const { + data: { did }, + } = await entrywayAgent.api.com.atproto.server.createAccount({ + email: 'alice@test.com', + handle: 'alice.test', + password: 'test123', + }) + await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() + + // @TODO move these steps into account creation process + await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) + await plc + .getClient() + .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) + await plc + .getClient() + .updateAtprotoKey( + did, + pds.ctx.plcRotationKey, + pds.ctx.repoSigningKey.did(), + ) + + alice = did + await pdsAgent.api.com.atproto.server.createAccount({ + did, + email: 'alice@test.com', + handle: 'alice.test', + password: 'test123', + }) + + const entrywayAccount = await entryway.ctx.services + .account(entryway.ctx.db) + .getAccount(did) + assert(entrywayAccount) + expect(entrywayAccount.did).toBe(did) + expect(entrywayAccount.pdsId).not.toBe(null) + expect(entrywayAccount.pdsDid).toBe(pds.ctx.cfg.service.did) + expect(entrywayAccount.root).toBe(null) + + const pdsAccount = await pds.ctx.services + .account(pds.ctx.db) + .getAccount(did) + assert(pdsAccount) + expect(pdsAccount.did).toBe(did) + expect(pdsAccount.pdsId).toBe(null) + expect(pdsAccount.pdsDid).toBe(null) + expect(pdsAccount.root).not.toBe(null) + }) + + it('creates a session that auths across services.', async () => { + const { data: session } = + await entrywayAgent.api.com.atproto.server.createSession({ + identifier: alice, + password: 'test123', + }) + accessToken = session.accessJwt + const tokenHeader = jose.decodeProtectedHeader(accessToken) + expect(tokenHeader.alg).toBe('ES256K') // asymmetric, from the jwt key and not the secret + const { data: entrywayResult } = + await entrywayAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + const { data: pdsResult } = + await pdsAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + expect(entrywayResult.did).toBe(alice) + expect(pdsResult.did).toBe(alice) + }) + + it('proxies writes to pds.', async () => { + const { data: profileRef } = + await entrywayAgent.com.atproto.repo.createRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice' }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: profile } = await pdsAgent.com.atproto.repo.getRecord({ + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profile.cid).toBe(profileRef.cid) + const { data: profileRefUpdated } = + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice!' }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: profileUpdated } = await pdsAgent.com.atproto.repo.getRecord({ + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profileUpdated.cid).toBe(profileRefUpdated.cid) + await entrywayAgent.com.atproto.repo.deleteRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const tryGetProfile = pdsAgent.com.atproto.repo.getRecord({ + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + await expect(tryGetProfile).rejects.toThrow('Could not locate record') + }) + + it('proxies blob uploads to pds.', async () => { + const file = await fs.readFile('tests/sample-img/key-portrait-small.jpg') + const { + data: { blob }, + } = await entrywayAgent.api.com.atproto.repo.uploadBlob(file, { + encoding: 'image/jpeg', + headers: SeedClient.getHeaders(accessToken), + }) + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice', avatar: blob }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: bytes } = await pdsAgent.com.atproto.sync.getBlob({ + did: alice, + cid: blob.ref.toString(), + }) + expect(Buffer.compare(file, bytes)).toBe(0) + }) + + it('proxies repo reads to pds.', async () => { + const { data: profileRef } = await entrywayAgent.com.atproto.repo.putRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice' }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + const { data: results } = await entrywayAgent.com.atproto.repo.listRecords({ + repo: alice, + collection: ids.AppBskyActorProfile, + }) + expect(results.records.map((record) => record.uri)).toContain( + profileRef.uri, + ) + }) + + it('initiates token refresh when account moves off of entryway.', async () => { + const { + data: { did, ...initialSession }, + } = await entrywayAgent.api.com.atproto.server.createAccount({ + email: 'bob@test.com', + handle: 'bob.test', + password: 'test123', + }) + // use initial session credentials for a write + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Bob' }, + }, + { + headers: SeedClient.getHeaders(initialSession.accessJwt), + encoding: 'application/json', + }, + ) + // now move bob to a separate pds + await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) + await plc + .getClient() + .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) + await plc + .getClient() + .updateAtprotoKey( + did, + pds.ctx.plcRotationKey, + pds.ctx.repoSigningKey.did(), + ) + const { id: pdsId } = await entryway.ctx.db.db + .selectFrom('pds') + .selectAll() + .executeTakeFirstOrThrow() + await entryway.ctx.db.db + .updateTable('user_account') + .set({ pdsId }) + .where('did', '=', did) + .returningAll() + .executeTakeFirst() + await pdsAgent.api.com.atproto.server.createAccount({ + did, + email: 'bob@test.com', + handle: 'bob.test', + password: 'test123', + }) + // attempt a write again on bob's original pds with same creds + const tryPutRecord = entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Bob!' }, + }, + { + headers: SeedClient.getHeaders(initialSession.accessJwt), + encoding: 'application/json', + }, + ) + await expect(tryPutRecord).rejects.toThrow('Token audience is out of date') + const err = await tryPutRecord.catch((err) => err) + expect(err.status).toBe(400) + expect(err.error).toBe('ExpiredToken') + // refresh session and try again + const { data: session } = + await entrywayAgent.com.atproto.server.refreshSession(undefined, { + headers: SeedClient.getHeaders(initialSession.refreshJwt), + }) + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Bob!' }, + }, + { + headers: SeedClient.getHeaders(session.accessJwt), + encoding: 'application/json', + }, + ) + const { data: profile } = await pdsAgent.com.atproto.repo.getRecord({ + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profile.value['displayName']).toEqual('Bob!') + }) + + it('initiates token refresh when account moves off of pds.', async () => { + const { + data: { did, ...initialSession }, + } = await entrywayAgent.api.com.atproto.server.createAccount({ + email: 'carol@test.com', + handle: 'carol.test', + password: 'test123', + }) + const outdatedAccessToken = await entryway.ctx.auth.createAccessToken({ + did, + pdsDid: pds.ctx.cfg.service.did, // pretending that carol was previously on this pds + }) + // attempt a write again on carol's previous pds with same creds + const tryPutRecord = entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Carol!' }, + }, + { + headers: SeedClient.getHeaders(outdatedAccessToken), + encoding: 'application/json', + }, + ) + await expect(tryPutRecord).rejects.toThrow('Token audience is out of date') + const err = await tryPutRecord.catch((err) => err) + expect(err.status).toBe(400) + expect(err.error).toBe('ExpiredToken') + // refresh session and try again + const { data: session } = + await entrywayAgent.com.atproto.server.refreshSession(undefined, { + headers: SeedClient.getHeaders(initialSession.refreshJwt), + }) + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Carol!' }, + }, + { + headers: SeedClient.getHeaders(session.accessJwt), + encoding: 'application/json', + }, + ) + const { data: profile } = await entrywayAgent.com.atproto.repo.getRecord({ + repo: did, + collection: ids.AppBskyActorProfile, + rkey: 'self', + }) + expect(profile.value['displayName']).toEqual('Carol!') + }) +}) diff --git a/packages/pds/tests/multi-pds/auth.test.ts b/packages/pds/tests/multi-pds/auth.test.ts deleted file mode 100644 index 492b5d75569..00000000000 --- a/packages/pds/tests/multi-pds/auth.test.ts +++ /dev/null @@ -1,403 +0,0 @@ -import assert from 'node:assert' -import fs from 'node:fs/promises' -import * as ui8 from 'uint8arrays' -import * as jose from 'jose' -import AtpAgent from '@atproto/api' -import { Secp256k1Keypair } from '@atproto/crypto' -import { - SeedClient, - TestPds, - TestPlc, - mockNetworkUtilities, -} from '@atproto/dev-env' -import { ids } from '@atproto/api/src/client/lexicons' - -describe('multi-pds auth', () => { - let plc: TestPlc - let entryway: TestPds - let entrywayAgent: AtpAgent - let pds: TestPds - let pdsAgent: AtpAgent - let alice: string - let accessToken: string - - beforeAll(async () => { - const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) - const jwtSigningPriv = ui8.toString(await jwtSigningKey.export(), 'hex') - const jwtVerifyPub = jwtSigningKey.publicKeyStr('hex') - const plcRotationKey = await Secp256k1Keypair.create({ exportable: true }) - const plcRotationPriv = ui8.toString(await plcRotationKey.export(), 'hex') - const recoveryKey = (await Secp256k1Keypair.create()).did() - plc = await TestPlc.create({}) - entryway = await TestPds.create({ - dbPostgresUrl: process.env.DB_POSTGRES_URL, - dbPostgresSchema: 'multi_pds_account_entryway', - didPlcUrl: plc.url, - recoveryDidKey: recoveryKey, - jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, - plcRotationKeyK256PrivateKeyHex: plcRotationPriv, - }) - pds = await TestPds.create({ - isEntryway: false, - dbPostgresUrl: process.env.DB_POSTGRES_URL, - dbPostgresSchema: 'multi_pds_account_pds', - didPlcUrl: plc.url, - recoveryDidKey: recoveryKey, - jwtVerifyKeyK256PublicKeyHex: jwtVerifyPub, - jwtSigningKeyK256PrivateKeyHex: undefined, // no private key material on pds for jwts - plcRotationKeyK256PrivateKeyHex: plcRotationPriv, - }) - 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() - mockNetworkUtilities([entryway, pds]) - entrywayAgent = entryway.getClient() - pdsAgent = pds.getClient() - }) - - afterAll(async () => { - await plc.close() - await entryway.close() - await pds.close() - }) - - it('assigns user to a pds.', async () => { - await entryway.ctx.db.db.updateTable('pds').set({ weight: 1 }).execute() - const { - data: { did }, - } = await entrywayAgent.api.com.atproto.server.createAccount({ - email: 'alice@test.com', - handle: 'alice.test', - password: 'test123', - }) - await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() - - // @TODO move these steps into account creation process - await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) - await plc - .getClient() - .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) - await plc - .getClient() - .updateAtprotoKey( - did, - pds.ctx.plcRotationKey, - pds.ctx.repoSigningKey.did(), - ) - - alice = did - await pdsAgent.api.com.atproto.server.createAccount({ - did, - email: 'alice@test.com', - handle: 'alice.test', - password: 'test123', - }) - - const entrywayAccount = await entryway.ctx.services - .account(entryway.ctx.db) - .getAccount(did) - assert(entrywayAccount) - expect(entrywayAccount.did).toBe(did) - expect(entrywayAccount.pdsId).not.toBe(null) - expect(entrywayAccount.pdsDid).toBe(pds.ctx.cfg.service.did) - expect(entrywayAccount.root).toBe(null) - - const pdsAccount = await pds.ctx.services - .account(pds.ctx.db) - .getAccount(did) - assert(pdsAccount) - expect(pdsAccount.did).toBe(did) - expect(pdsAccount.pdsId).toBe(null) - expect(pdsAccount.pdsDid).toBe(null) - expect(pdsAccount.root).not.toBe(null) - }) - - it('creates a session that auths across services.', async () => { - const { data: session } = - await entrywayAgent.api.com.atproto.server.createSession({ - identifier: alice, - password: 'test123', - }) - accessToken = session.accessJwt - const tokenHeader = jose.decodeProtectedHeader(accessToken) - expect(tokenHeader.alg).toBe('ES256K') // asymmetric, from the jwt key and not the secret - const { data: entrywayResult } = - await entrywayAgent.api.com.atproto.server.getSession( - {}, - { headers: SeedClient.getHeaders(accessToken) }, - ) - const { data: pdsResult } = - await pdsAgent.api.com.atproto.server.getSession( - {}, - { headers: SeedClient.getHeaders(accessToken) }, - ) - expect(entrywayResult.did).toBe(alice) - expect(pdsResult.did).toBe(alice) - }) - - describe('entryway', () => { - it('proxies writes to pds.', async () => { - const { data: profileRef } = - await entrywayAgent.com.atproto.repo.createRecord( - { - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Alice' }, - }, - { - headers: SeedClient.getHeaders(accessToken), - encoding: 'application/json', - }, - ) - const { data: profile } = await pdsAgent.com.atproto.repo.getRecord({ - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - }) - expect(profile.cid).toBe(profileRef.cid) - const { data: profileRefUpdated } = - await entrywayAgent.com.atproto.repo.putRecord( - { - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Alice!' }, - }, - { - headers: SeedClient.getHeaders(accessToken), - encoding: 'application/json', - }, - ) - const { data: profileUpdated } = - await pdsAgent.com.atproto.repo.getRecord({ - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - }) - expect(profileUpdated.cid).toBe(profileRefUpdated.cid) - await entrywayAgent.com.atproto.repo.deleteRecord( - { - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - }, - { - headers: SeedClient.getHeaders(accessToken), - encoding: 'application/json', - }, - ) - const tryGetProfile = pdsAgent.com.atproto.repo.getRecord({ - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - }) - await expect(tryGetProfile).rejects.toThrow('Could not locate record') - }) - - it('proxies blob uploads to pds.', async () => { - const file = await fs.readFile('tests/sample-img/key-portrait-small.jpg') - const { - data: { blob }, - } = await entrywayAgent.api.com.atproto.repo.uploadBlob(file, { - encoding: 'image/jpeg', - headers: SeedClient.getHeaders(accessToken), - }) - await entrywayAgent.com.atproto.repo.putRecord( - { - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Alice', avatar: blob }, - }, - { - headers: SeedClient.getHeaders(accessToken), - encoding: 'application/json', - }, - ) - const { data: bytes } = await pdsAgent.com.atproto.sync.getBlob({ - did: alice, - cid: blob.ref.toString(), - }) - expect(Buffer.compare(file, bytes)).toBe(0) - }) - - it('proxies repo reads to pds.', async () => { - const { data: profileRef } = - await entrywayAgent.com.atproto.repo.putRecord( - { - repo: alice, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Alice' }, - }, - { - headers: SeedClient.getHeaders(accessToken), - encoding: 'application/json', - }, - ) - const { data: results } = - await entrywayAgent.com.atproto.repo.listRecords({ - repo: alice, - collection: ids.AppBskyActorProfile, - }) - expect(results.records.map((record) => record.uri)).toContain( - profileRef.uri, - ) - }) - - it('initiates token refresh when account moves off of entryway.', async () => { - const { - data: { did, ...initialSession }, - } = await entrywayAgent.api.com.atproto.server.createAccount({ - email: 'bob@test.com', - handle: 'bob.test', - password: 'test123', - }) - // use initial session credentials for a write - await entrywayAgent.com.atproto.repo.putRecord( - { - repo: did, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Bob' }, - }, - { - headers: SeedClient.getHeaders(initialSession.accessJwt), - encoding: 'application/json', - }, - ) - // now move bob to a separate pds - await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) - await plc - .getClient() - .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) - await plc - .getClient() - .updateAtprotoKey( - did, - pds.ctx.plcRotationKey, - pds.ctx.repoSigningKey.did(), - ) - const { id: pdsId } = await entryway.ctx.db.db - .selectFrom('pds') - .selectAll() - .executeTakeFirstOrThrow() - await entryway.ctx.db.db - .updateTable('user_account') - .set({ pdsId }) - .where('did', '=', did) - .returningAll() - .executeTakeFirst() - await pdsAgent.api.com.atproto.server.createAccount({ - did, - email: 'bob@test.com', - handle: 'bob.test', - password: 'test123', - }) - // attempt a write again on bob's original pds with same creds - const tryPutRecord = entrywayAgent.com.atproto.repo.putRecord( - { - repo: did, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Bob!' }, - }, - { - headers: SeedClient.getHeaders(initialSession.accessJwt), - encoding: 'application/json', - }, - ) - await expect(tryPutRecord).rejects.toThrow( - 'Token audience is out of date', - ) - const err = await tryPutRecord.catch((err) => err) - expect(err.status).toBe(400) - expect(err.error).toBe('ExpiredToken') - // refresh session and try again - const { data: session } = - await entrywayAgent.com.atproto.server.refreshSession(undefined, { - headers: SeedClient.getHeaders(initialSession.refreshJwt), - }) - await entrywayAgent.com.atproto.repo.putRecord( - { - repo: did, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Bob!' }, - }, - { - headers: SeedClient.getHeaders(session.accessJwt), - encoding: 'application/json', - }, - ) - const { data: profile } = await pdsAgent.com.atproto.repo.getRecord({ - repo: did, - collection: ids.AppBskyActorProfile, - rkey: 'self', - }) - expect(profile.value['displayName']).toEqual('Bob!') - }) - - it('initiates token refresh when account moves off of pds.', async () => { - const { - data: { did, ...initialSession }, - } = await entrywayAgent.api.com.atproto.server.createAccount({ - email: 'carol@test.com', - handle: 'carol.test', - password: 'test123', - }) - const outdatedAccessToken = await entryway.ctx.auth.createAccessToken({ - did, - pdsDid: pds.ctx.cfg.service.did, // pretending that carol was previously on this pds - }) - // attempt a write again on carol's previous pds with same creds - const tryPutRecord = entrywayAgent.com.atproto.repo.putRecord( - { - repo: did, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Carol!' }, - }, - { - headers: SeedClient.getHeaders(outdatedAccessToken), - encoding: 'application/json', - }, - ) - await expect(tryPutRecord).rejects.toThrow( - 'Token audience is out of date', - ) - const err = await tryPutRecord.catch((err) => err) - expect(err.status).toBe(400) - expect(err.error).toBe('ExpiredToken') - // refresh session and try again - const { data: session } = - await entrywayAgent.com.atproto.server.refreshSession(undefined, { - headers: SeedClient.getHeaders(initialSession.refreshJwt), - }) - await entrywayAgent.com.atproto.repo.putRecord( - { - repo: did, - collection: ids.AppBskyActorProfile, - rkey: 'self', - record: { displayName: 'Carol!' }, - }, - { - headers: SeedClient.getHeaders(session.accessJwt), - encoding: 'application/json', - }, - ) - const { data: profile } = await entrywayAgent.com.atproto.repo.getRecord({ - repo: did, - collection: ids.AppBskyActorProfile, - rkey: 'self', - }) - expect(profile.value['displayName']).toEqual('Carol!') - }) - }) -}) From 98dbf17d3fb504f9e8eeb5c352b813489ce14b32 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 12 Oct 2023 17:25:54 -0400 Subject: [PATCH 022/135] tidy tests --- packages/pds/tests/entryway.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts index 30672971b52..958ad0a6987 100644 --- a/packages/pds/tests/entryway.test.ts +++ b/packages/pds/tests/entryway.test.ts @@ -20,6 +20,7 @@ describe('entryway', () => { let pdsAgent: AtpAgent let alice: string let accessToken: string + let pdsId: number beforeAll(async () => { const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) @@ -47,14 +48,16 @@ describe('entryway', () => { jwtSigningKeyK256PrivateKeyHex: undefined, // no private key material on pds for jwts plcRotationKeyK256PrivateKeyHex: plcRotationPriv, }) - await entryway.ctx.db.db + const pdsRow = 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() + .returningAll() + .executeTakeFirstOrThrow() + pdsId = pdsRow.id mockNetworkUtilities([entryway, pds]) entrywayAgent = entryway.getClient() pdsAgent = pds.getClient() @@ -75,6 +78,7 @@ describe('entryway', () => { handle: 'alice.test', password: 'test123', }) + alice = did await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() // @TODO move these steps into account creation process @@ -90,7 +94,6 @@ describe('entryway', () => { pds.ctx.repoSigningKey.did(), ) - alice = did await pdsAgent.api.com.atproto.server.createAccount({ did, email: 'alice@test.com', @@ -103,7 +106,7 @@ describe('entryway', () => { .getAccount(did) assert(entrywayAccount) expect(entrywayAccount.did).toBe(did) - expect(entrywayAccount.pdsId).not.toBe(null) + expect(entrywayAccount.pdsId).toBe(pdsId) expect(entrywayAccount.pdsDid).toBe(pds.ctx.cfg.service.did) expect(entrywayAccount.root).toBe(null) @@ -280,10 +283,6 @@ describe('entryway', () => { pds.ctx.plcRotationKey, pds.ctx.repoSigningKey.did(), ) - const { id: pdsId } = await entryway.ctx.db.db - .selectFrom('pds') - .selectAll() - .executeTakeFirstOrThrow() await entryway.ctx.db.db .updateTable('user_account') .set({ pdsId }) From f7720d89893bf414b5e4f00b9deebcece33bcf2f Mon Sep 17 00:00:00 2001 From: devin ivy Date: Fri, 13 Oct 2023 15:26:16 -0400 Subject: [PATCH 023/135] Proxy for accounts across PDSes (#1724) * scratch work on multi-pds auth * move from jsonwebtoken to jose package, impl secp256k1 auth token * pds assignment, pds in tokens * move authPassthru util * setup proxying repo write ops * authed and unauthed proxying on com.atproto.repo endpoints * unify authed and unauthed pds proxying * adapt admin endpoints for multi-pds * pds-proxy report creation and app.bsky endpoints * fix * cleanup ensuring on correct pds, add to uploadBlob * tidy * tidy key promises --- packages/dev-env/src/pds.ts | 3 + packages/pds/package.json | 4 +- .../src/api/app/bsky/actor/getPreferences.ts | 1 + .../pds/src/api/app/bsky/actor/getProfile.ts | 19 +- .../pds/src/api/app/bsky/actor/getProfiles.ts | 18 +- .../src/api/app/bsky/actor/getSuggestions.ts | 18 +- .../src/api/app/bsky/actor/putPreferences.ts | 1 + .../src/api/app/bsky/actor/searchActors.ts | 18 +- .../app/bsky/actor/searchActorsTypeahead.ts | 18 +- .../src/api/app/bsky/feed/getActorFeeds.ts | 18 +- .../src/api/app/bsky/feed/getActorLikes.ts | 19 +- .../src/api/app/bsky/feed/getAuthorFeed.ts | 19 +- packages/pds/src/api/app/bsky/feed/getFeed.ts | 18 +- .../src/api/app/bsky/feed/getFeedGenerator.ts | 18 +- .../api/app/bsky/feed/getFeedGenerators.ts | 18 +- .../pds/src/api/app/bsky/feed/getLikes.ts | 18 +- .../pds/src/api/app/bsky/feed/getListFeed.ts | 18 +- .../src/api/app/bsky/feed/getPostThread.ts | 19 +- .../pds/src/api/app/bsky/feed/getPosts.ts | 18 +- .../src/api/app/bsky/feed/getRepostedBy.ts | 18 +- .../api/app/bsky/feed/getSuggestedFeeds.ts | 18 +- .../pds/src/api/app/bsky/feed/getTimeline.ts | 18 +- .../pds/src/api/app/bsky/graph/getBlocks.ts | 18 +- .../src/api/app/bsky/graph/getFollowers.ts | 19 +- .../pds/src/api/app/bsky/graph/getFollows.ts | 19 +- .../pds/src/api/app/bsky/graph/getList.ts | 18 +- .../src/api/app/bsky/graph/getListBlocks.ts | 18 +- .../src/api/app/bsky/graph/getListMutes.ts | 18 +- .../pds/src/api/app/bsky/graph/getLists.ts | 18 +- .../pds/src/api/app/bsky/graph/getMutes.ts | 18 +- .../bsky/graph/getSuggestedFollowsByActor.ts | 19 +- .../pds/src/api/app/bsky/graph/muteActor.ts | 18 +- .../src/api/app/bsky/graph/muteActorList.ts | 18 +- .../pds/src/api/app/bsky/graph/unmuteActor.ts | 18 +- .../src/api/app/bsky/graph/unmuteActorList.ts | 18 +- .../app/bsky/notification/getUnreadCount.ts | 18 +- .../bsky/notification/listNotifications.ts | 19 +- .../api/app/bsky/notification/registerPush.ts | 17 +- .../api/app/bsky/notification/updateSeen.ts | 18 +- .../src/api/app/bsky/unspecced/getPopular.ts | 18 +- .../unspecced/getPopularFeedGenerators.ts | 19 +- .../com/atproto/admin/getModerationAction.ts | 3 +- .../com/atproto/admin/getModerationActions.ts | 2 +- .../com/atproto/admin/getModerationReport.ts | 3 +- .../com/atproto/admin/getModerationReports.ts | 2 +- .../src/api/com/atproto/admin/getRecord.ts | 39 ++-- .../pds/src/api/com/atproto/admin/getRepo.ts | 9 +- .../atproto/admin/resolveModerationReports.ts | 2 +- .../atproto/admin/reverseModerationAction.ts | 2 +- .../src/api/com/atproto/admin/searchRepos.ts | 2 +- .../com/atproto/admin/takeModerationAction.ts | 2 +- .../pds/src/api/com/atproto/admin/util.ts | 24 --- .../com/atproto/moderation/createReport.ts | 18 +- .../src/api/com/atproto/repo/applyWrites.ts | 19 +- .../src/api/com/atproto/repo/createRecord.ts | 25 ++- .../src/api/com/atproto/repo/deleteRecord.ts | 19 +- .../src/api/com/atproto/repo/describeRepo.ts | 13 +- .../pds/src/api/com/atproto/repo/getRecord.ts | 35 ++-- .../src/api/com/atproto/repo/listRecords.ts | 15 +- .../pds/src/api/com/atproto/repo/putRecord.ts | 25 ++- .../src/api/com/atproto/repo/uploadBlob.ts | 26 ++- .../api/com/atproto/server/createAccount.ts | 41 +++- .../api/com/atproto/server/createSession.ts | 26 ++- .../api/com/atproto/server/deleteSession.ts | 9 +- .../api/com/atproto/server/refreshSession.ts | 19 +- packages/pds/src/api/proxy.ts | 89 +++++++++ packages/pds/src/auth.ts | 185 ++++++++++++------ packages/pds/src/config/env.ts | 4 + packages/pds/src/config/secrets.ts | 10 + packages/pds/src/context.ts | 10 +- packages/pds/src/db/database-schema.ts | 2 + .../20231004T040354739Z-user-account-pds.ts | 25 +++ packages/pds/src/db/migrations/index.ts | 1 + packages/pds/src/db/tables/pds.ts | 11 ++ packages/pds/src/db/tables/user-account.ts | 1 + packages/pds/src/index.ts | 2 - packages/pds/src/logger.ts | 4 +- packages/pds/src/services/account/index.ts | 26 ++- packages/pds/tests/app-passwords.test.ts | 14 +- packages/pds/tests/auth.test.ts | 23 +-- pnpm-lock.yaml | 91 +-------- 81 files changed, 1240 insertions(+), 321 deletions(-) create mode 100644 packages/pds/src/api/proxy.ts create mode 100644 packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts create mode 100644 packages/pds/src/db/tables/pds.ts diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index 501ae390cdb..eb11e35c6c2 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -20,6 +20,8 @@ export class TestPds { ) {} static async create(config: PdsConfig): Promise { + const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true }) + const jwtSigningPriv = ui8.toString(await jwtSigningKey.export(), 'hex') const repoSigningKey = await Secp256k1Keypair.create({ exportable: true }) const repoSigningPriv = ui8.toString(await repoSigningKey.export(), 'hex') const plcRotationKey = await Secp256k1Keypair.create({ exportable: true }) @@ -39,6 +41,7 @@ export class TestPds { moderatorPassword: MOD_PASSWORD, triagePassword: TRIAGE_PASSWORD, jwtSecret: 'jwt-secret', + jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, serviceHandleDomains: ['.test'], sequencerLeaderLockId: uniqueLockId(), bskyAppViewUrl: 'https://appview.invalid', diff --git a/packages/pds/package.json b/packages/pds/package.json index 4c22a61133b..e4f94418296 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -56,7 +56,8 @@ "http-errors": "^2.0.0", "http-terminator": "^3.2.0", "ioredis": "^5.3.2", - "jsonwebtoken": "^8.5.1", + "jose": "^4.15.2", + "key-encoder": "^2.0.3", "kysely": "^0.22.0", "multiformats": "^9.9.0", "nodemailer": "^6.8.0", @@ -80,7 +81,6 @@ "@types/disposable-email": "^0.2.0", "@types/express": "^4.17.13", "@types/express-serve-static-core": "^4.17.36", - "@types/jsonwebtoken": "^8.5.9", "@types/nodemailer": "^6.4.6", "@types/pg": "^8.6.6", "@types/qs": "^6.9.7", diff --git a/packages/pds/src/api/app/bsky/actor/getPreferences.ts b/packages/pds/src/api/app/bsky/actor/getPreferences.ts index 1bca50f0bd1..415114cb548 100644 --- a/packages/pds/src/api/app/bsky/actor/getPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/getPreferences.ts @@ -2,6 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { AuthScope } from '../../../../auth' +// @TODO may need to proxy to pds export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getPreferences({ auth: ctx.accessVerifier, diff --git a/packages/pds/src/api/app/bsky/actor/getProfile.ts b/packages/pds/src/api/app/bsky/actor/getProfile.ts index c200e1dd75f..648ad204f92 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfile.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfile.ts @@ -1,14 +1,31 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../../api/com/atproto/admin/util' import { OutputSchema } from '../../../../lexicon/types/app/bsky/actor/getProfile' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getProfile({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, auth, params }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.getProfile( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.actor.getProfile( diff --git a/packages/pds/src/api/app/bsky/actor/getProfiles.ts b/packages/pds/src/api/app/bsky/actor/getProfiles.ts index ebec9e36938..ed5c10a6552 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfiles.ts @@ -2,12 +2,28 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { OutputSchema } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import { LocalRecords } from '../../../../services/local' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' import { handleReadAfterWrite } from '../util/read-after-write' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getProfiles({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.getProfiles( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.getProfiles( params, diff --git a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts index e6c72e5c830..c2dd11cfd98 100644 --- a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getSuggestions({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.getSuggestions( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.getSuggestions( params, diff --git a/packages/pds/src/api/app/bsky/actor/putPreferences.ts b/packages/pds/src/api/app/bsky/actor/putPreferences.ts index 27528595116..ce2fd5e39cb 100644 --- a/packages/pds/src/api/app/bsky/actor/putPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/putPreferences.ts @@ -3,6 +3,7 @@ import AppContext from '../../../../context' import { UserPreference } from '../../../../services/account' import { InvalidRequestError } from '@atproto/xrpc-server' +// @TODO may need to proxy to pds export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.putPreferences({ auth: ctx.accessVerifierCheckTakedown, diff --git a/packages/pds/src/api/app/bsky/actor/searchActors.ts b/packages/pds/src/api/app/bsky/actor/searchActors.ts index 3f1bd2355d6..c8b9c52a726 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActors.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActors.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActors({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.searchActors( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.searchActors( params, diff --git a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts index a637aea69c7..92e52b66f66 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActorsTypeahead({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.searchActorsTypeahead( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.actor.searchActorsTypeahead( diff --git a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts index da99617178f..fb706591cb2 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getActorFeeds({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getActorFeeds( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getActorFeeds( params, diff --git a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts index 9c0c38c5a20..786777cfde9 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts @@ -2,13 +2,30 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' import { handleReadAfterWrite } from '../util/read-after-write' -import { authPassthru } from '../../../../api/com/atproto/admin/util' import { LocalRecords } from '../../../../services/local' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getActorLikes({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getActorLikes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null diff --git a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts index 6563812fb9a..a19cea98bc7 100644 --- a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts @@ -2,14 +2,31 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' import { handleReadAfterWrite } from '../util/read-after-write' -import { authPassthru } from '../../../../api/com/atproto/admin/util' import { LocalRecords } from '../../../../services/local' import { isReasonRepost } from '../../../../lexicon/types/app/bsky/feed/defs' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getAuthorFeed({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getAuthorFeed( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.feed.getAuthorFeed( diff --git a/packages/pds/src/api/app/bsky/feed/getFeed.ts b/packages/pds/src/api/app/bsky/feed/getFeed.ts index 051b0c7bcdf..fa7a8ec91a6 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeed.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeed({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getFeed( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const { data: feed } = diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts index 28c404b58e8..c55bceb608d 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerator({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getFeedGenerator( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getFeedGenerator( params, diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts index 12cf9e91c0a..083dd510107 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerators({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getFeedGenerators( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getFeedGenerators( params, diff --git a/packages/pds/src/api/app/bsky/feed/getLikes.ts b/packages/pds/src/api/app/bsky/feed/getLikes.ts index 771cc511cd4..37bb74461cf 100644 --- a/packages/pds/src/api/app/bsky/feed/getLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getLikes.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getLikes({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getLikes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getLikes( params, diff --git a/packages/pds/src/api/app/bsky/feed/getListFeed.ts b/packages/pds/src/api/app/bsky/feed/getListFeed.ts index 34b8630a933..8b7ce45d5dc 100644 --- a/packages/pds/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getListFeed.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getListFeed({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getListFeed( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getListFeed( params, diff --git a/packages/pds/src/api/app/bsky/feed/getPostThread.ts b/packages/pds/src/api/app/bsky/feed/getPostThread.ts index 270c1044497..34afae8223f 100644 --- a/packages/pds/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/pds/src/api/app/bsky/feed/getPostThread.ts @@ -22,12 +22,29 @@ import { getRepoRev, handleReadAfterWrite, } from '../util/read-after-write' -import { authPassthru } from '../../../com/atproto/admin/util' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getPostThread({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getPostThread( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null diff --git a/packages/pds/src/api/app/bsky/feed/getPosts.ts b/packages/pds/src/api/app/bsky/feed/getPosts.ts index 1b755450f63..140844dec54 100644 --- a/packages/pds/src/api/app/bsky/feed/getPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/getPosts.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getPosts({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getPosts( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getPosts( params, diff --git a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts index 30e72b434e6..3936fcdbb67 100644 --- a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getRepostedBy({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getRepostedBy( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getRepostedBy( params, diff --git a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts index 733405b3b42..de3fc730880 100644 --- a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getSuggestedFeeds({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getSuggestedFeeds( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getSuggestedFeeds( params, diff --git a/packages/pds/src/api/app/bsky/feed/getTimeline.ts b/packages/pds/src/api/app/bsky/feed/getTimeline.ts index 2c3e2ed44d6..f6ea5254f16 100644 --- a/packages/pds/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/pds/src/api/app/bsky/feed/getTimeline.ts @@ -3,11 +3,27 @@ import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getTimeline' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getTimeline({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.getTimeline( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.feed.getTimeline( params, diff --git a/packages/pds/src/api/app/bsky/graph/getBlocks.ts b/packages/pds/src/api/app/bsky/graph/getBlocks.ts index f66eb64b945..788388f9446 100644 --- a/packages/pds/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getBlocks.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getBlocks({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getBlocks( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getBlocks( params, diff --git a/packages/pds/src/api/app/bsky/graph/getFollowers.ts b/packages/pds/src/api/app/bsky/graph/getFollowers.ts index 389f92d4e14..d187709311b 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollowers.ts @@ -1,11 +1,28 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../../api/com/atproto/admin/util' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollowers({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getFollowers( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.graph.getFollowers( diff --git a/packages/pds/src/api/app/bsky/graph/getFollows.ts b/packages/pds/src/api/app/bsky/graph/getFollows.ts index 343fd81d414..7ad08e8eaf4 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollows.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollows.ts @@ -1,11 +1,28 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from '../../../../api/com/atproto/admin/util' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollows({ auth: ctx.accessOrRoleVerifier, handler: async ({ req, params, auth }) => { + if (auth.credentials.type === 'access') { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getFollows( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + } + const requester = auth.credentials.type === 'access' ? auth.credentials.did : null const res = await ctx.appViewAgent.api.app.bsky.graph.getFollows( diff --git a/packages/pds/src/api/app/bsky/graph/getList.ts b/packages/pds/src/api/app/bsky/graph/getList.ts index 061d6759c2c..919d149c428 100644 --- a/packages/pds/src/api/app/bsky/graph/getList.ts +++ b/packages/pds/src/api/app/bsky/graph/getList.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getList({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getList( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getList( params, diff --git a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts index 83975782fa4..17d112f52d7 100644 --- a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListBlocks({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getListBlocks( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getListBlocks( params, diff --git a/packages/pds/src/api/app/bsky/graph/getListMutes.ts b/packages/pds/src/api/app/bsky/graph/getListMutes.ts index 05f6ce1ab09..9b023007c90 100644 --- a/packages/pds/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getListMutes.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListMutes({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getListMutes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getListMutes( params, diff --git a/packages/pds/src/api/app/bsky/graph/getLists.ts b/packages/pds/src/api/app/bsky/graph/getLists.ts index 6c8f6452ea4..b752f09dd91 100644 --- a/packages/pds/src/api/app/bsky/graph/getLists.ts +++ b/packages/pds/src/api/app/bsky/graph/getLists.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getLists({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getLists( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getLists( params, diff --git a/packages/pds/src/api/app/bsky/graph/getMutes.ts b/packages/pds/src/api/app/bsky/graph/getMutes.ts index 12ff1a032a0..cdd666a912c 100644 --- a/packages/pds/src/api/app/bsky/graph/getMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getMutes.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getMutes({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.graph.getMutes( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getMutes( params, diff --git a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index 53125cbc517..7ca10f644ae 100644 --- a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -1,10 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getSuggestedFollowsByActor({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = + await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.graph.getSuggestedFollowsByActor( diff --git a/packages/pds/src/api/app/bsky/graph/muteActor.ts b/packages/pds/src/api/app/bsky/graph/muteActor.ts index 9f753bac926..5278b6d8323 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActor.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActor({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.muteActor( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.muteActor(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/muteActorList.ts b/packages/pds/src/api/app/bsky/graph/muteActorList.ts index 441571a26b9..20430074774 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActorList.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActorList({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.muteActorList( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.muteActorList(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts index 586b12565d6..87fee4a19f6 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActor({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.unmuteActor( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.unmuteActor(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts index e8ba9f8c4d4..c387b799893 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActorList({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { - const requester = auth.credentials.did + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.graph.unmuteActorList( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.graph.unmuteActorList(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts index c8b723403d5..31be13b9dba 100644 --- a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts @@ -1,10 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.getUnreadCount({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.notification.getUnreadCount( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.notification.getUnreadCount( diff --git a/packages/pds/src/api/app/bsky/notification/listNotifications.ts b/packages/pds/src/api/app/bsky/notification/listNotifications.ts index 48e75304af5..6b3e4c964a8 100644 --- a/packages/pds/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/pds/src/api/app/bsky/notification/listNotifications.ts @@ -1,10 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.listNotifications({ auth: ctx.accessVerifier, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = + await agent.api.app.bsky.notification.listNotifications( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.notification.listNotifications( diff --git a/packages/pds/src/api/app/bsky/notification/registerPush.ts b/packages/pds/src/api/app/bsky/notification/registerPush.ts index d5db39f1ac7..4c767c87728 100644 --- a/packages/pds/src/api/app/bsky/notification/registerPush.ts +++ b/packages/pds/src/api/app/bsky/notification/registerPush.ts @@ -4,11 +4,26 @@ import { getNotif } from '@atproto/identity' import { InvalidRequestError } from '@atproto/xrpc-server' import { AtpAgent } from '@atproto/api' import { getDidDoc } from '../util/resolver' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.registerPush({ auth: ctx.accessVerifier, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.notification.registerPush( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const { serviceDid } = input.body const { credentials: { did }, diff --git a/packages/pds/src/api/app/bsky/notification/updateSeen.ts b/packages/pds/src/api/app/bsky/notification/updateSeen.ts index 44fe4bc13cc..9808926a7ba 100644 --- a/packages/pds/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/pds/src/api/app/bsky/notification/updateSeen.ts @@ -1,12 +1,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.updateSeen({ auth: ctx.accessVerifier, - handler: async ({ input, auth }) => { - const requester = auth.credentials.did + handler: async ({ input, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.notification.updateSeen( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did await ctx.appViewAgent.api.app.bsky.notification.updateSeen(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopular.ts b/packages/pds/src/api/app/bsky/unspecced/getPopular.ts index f890ea7baed..dbe8a42738f 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopular.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopular.ts @@ -1,11 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getPopular({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.unspecced.getPopular( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const HOT_CLASSIC_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/hot-classic' diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index abc556cdb70..5ffbd417790 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -1,11 +1,28 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getPopularFeedGenerators({ auth: ctx.accessVerifier, - handler: async ({ auth, params }) => { + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = + await agent.api.app.bsky.unspecced.getPopularFeedGenerators( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const res = await ctx.appViewAgent.api.app.bsky.unspecced.getPopularFeedGenerators( diff --git a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts index 258ca9d94a1..1848591737c 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts @@ -1,7 +1,8 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' import { isRepoView } from '../../../../lexicon/types/com/atproto/admin/defs' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationAction({ diff --git a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts index 0ef48e99851..301aecef541 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationActions({ diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts index b75268ebdf8..34f28b8bd18 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts @@ -1,7 +1,8 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' import { isRepoView } from '../../../../lexicon/types/com/atproto/admin/defs' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReport({ diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts index 2d5dd329bc4..2135cbaa6d9 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReports({ diff --git a/packages/pds/src/api/com/atproto/admin/getRecord.ts b/packages/pds/src/api/com/atproto/admin/getRecord.ts index b68d01aefda..8ba48308f8e 100644 --- a/packages/pds/src/api/com/atproto/admin/getRecord.ts +++ b/packages/pds/src/api/com/atproto/admin/getRecord.ts @@ -2,7 +2,8 @@ import { AtUri } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRecord({ @@ -10,27 +11,27 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ req, params, auth }) => { const access = auth.credentials const { db, services } = ctx - const { uri, cid } = params - const result = await services - .record(db) - .getRecord(new AtUri(uri), cid ?? null, true) - const recordDetail = - result && - (await services.moderation(db).views.recordDetail(result, { - includeEmails: access.moderator, - })) + const { uri: uriStr, cid } = params + const uri = new AtUri(uriStr) if (ctx.cfg.bskyAppView.proxyModeration) { try { - const { data: recordDetailAppview } = - await ctx.appViewAgent.com.atproto.admin.getRecord( + const [{ data: recordDetailAppview }, account] = await Promise.all([ + ctx.appViewAgent.com.atproto.admin.getRecord( params, authPassthru(req), - ) - if (recordDetail) { + ), + services.account(db).getAccount(uri.host, true), + ]) + const localRepoView = + account && + (await services.moderation(db).views.repo(account, { + includeEmails: access.moderator, + })) + if (localRepoView) { recordDetailAppview.repo = mergeRepoViewPdsDetails( recordDetailAppview.repo, - recordDetail.repo, + localRepoView, ) } return { @@ -46,6 +47,14 @@ export default function (server: Server, ctx: AppContext) { } } + // @TODO when proxying fetch repo info directly rather than via record + const result = await services.record(db).getRecord(uri, cid ?? null, true) + const recordDetail = + result && + (await services.moderation(db).views.recordDetail(result, { + includeEmails: access.moderator, + })) + if (!recordDetail) { throw new InvalidRequestError('Record not found', 'RecordNotFound') } diff --git a/packages/pds/src/api/com/atproto/admin/getRepo.ts b/packages/pds/src/api/com/atproto/admin/getRepo.ts index 19e07862851..fed516916dc 100644 --- a/packages/pds/src/api/com/atproto/admin/getRepo.ts +++ b/packages/pds/src/api/com/atproto/admin/getRepo.ts @@ -1,7 +1,8 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { mergeRepoViewPdsDetails } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ @@ -10,10 +11,10 @@ export default function (server: Server, ctx: AppContext) { const access = auth.credentials const { db, services } = ctx const { did } = params - const result = await services.account(db).getAccount(did, true) + const account = await services.account(db).getAccount(did, true) const repoDetail = - result && - (await services.moderation(db).views.repoDetail(result, { + account && + (await services.moderation(db).views.repoDetail(account, { includeEmails: access.moderator, })) diff --git a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts index 52279745e46..c8fe76b7aad 100644 --- a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.resolveModerationReports({ diff --git a/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts b/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts index a8e8d62a3ad..1765b705212 100644 --- a/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts @@ -9,7 +9,7 @@ import { TAKEDOWN, } from '../../../../lexicon/types/com/atproto/admin/defs' import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.reverseModerationAction({ diff --git a/packages/pds/src/api/com/atproto/admin/searchRepos.ts b/packages/pds/src/api/com/atproto/admin/searchRepos.ts index da2d7fa3788..3c3fe0e0b5f 100644 --- a/packages/pds/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/api/com/atproto/admin/searchRepos.ts @@ -2,7 +2,7 @@ import { sql } from 'kysely' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { ListKeyset } from '../../../../services/account' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.searchRepos({ diff --git a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts index fb593b1c957..080c8cfb08d 100644 --- a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts @@ -11,7 +11,7 @@ import { import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' import { getSubject, getAction } from '../moderation/util' import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' -import { authPassthru } from './util' +import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.takeModerationAction({ diff --git a/packages/pds/src/api/com/atproto/admin/util.ts b/packages/pds/src/api/com/atproto/admin/util.ts index f8bab4460a5..841d3b2b9f2 100644 --- a/packages/pds/src/api/com/atproto/admin/util.ts +++ b/packages/pds/src/api/com/atproto/admin/util.ts @@ -1,32 +1,8 @@ -import express from 'express' import { RepoView, RepoViewDetail, } from '../../../../lexicon/types/com/atproto/admin/defs' -// Output designed to passed as second arg to AtpAgent methods. -// The encoding field here is a quirk of the AtpAgent. -export function authPassthru( - req: express.Request, - withEncoding?: false, -): { headers: { authorization: string }; encoding: undefined } | undefined - -export function authPassthru( - req: express.Request, - withEncoding: true, -): - | { headers: { authorization: string }; encoding: 'application/json' } - | undefined - -export function authPassthru(req: express.Request, withEncoding?: boolean) { - if (req.headers.authorization) { - return { - headers: { authorization: req.headers.authorization }, - encoding: withEncoding ? 'application/json' : undefined, - } - } -} - // @NOTE mutates. // merges-in details that the pds knows about the repo. export function mergeRepoViewPdsDetails( diff --git a/packages/pds/src/api/com/atproto/moderation/createReport.ts b/packages/pds/src/api/com/atproto/moderation/createReport.ts index 83cd5f454e0..6d7d1493cdb 100644 --- a/packages/pds/src/api/com/atproto/moderation/createReport.ts +++ b/packages/pds/src/api/com/atproto/moderation/createReport.ts @@ -1,11 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { getReasonType, getSubject } from './util' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ auth: ctx.accessVerifierCheckTakedown, - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.moderation.createReport( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did if (ctx.cfg.bskyAppView.proxyModeration) { diff --git a/packages/pds/src/api/com/atproto/repo/applyWrites.ts b/packages/pds/src/api/com/atproto/repo/applyWrites.ts index d5be8bb720d..868c6f682ed 100644 --- a/packages/pds/src/api/com/atproto/repo/applyWrites.ts +++ b/packages/pds/src/api/com/atproto/repo/applyWrites.ts @@ -15,6 +15,7 @@ import { } from '../../../../repo' import AppContext from '../../../../context' import { ConcurrentWriteError } from '../../../../services/repo' +import { proxy, authPassthru, ensureThisPds } from '../../../proxy' const ratelimitPoints = ({ input }: { input: HandlerInput }) => { let points = 0 @@ -46,7 +47,23 @@ export default function (server: Server, ctx: AppContext) { }, ], - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.com.atproto.repo.applyWrites( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + + ensureThisPds(ctx, auth.credentials.pdsDid) + const tx = input.body const { repo, validate, swapCommit } = tx const did = await ctx.services.account(ctx.db).getDidForActor(repo) diff --git a/packages/pds/src/api/com/atproto/repo/createRecord.ts b/packages/pds/src/api/com/atproto/repo/createRecord.ts index 26bc5614785..c59dd73d94b 100644 --- a/packages/pds/src/api/com/atproto/repo/createRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/createRecord.ts @@ -12,6 +12,12 @@ import AppContext from '../../../../context' import { ids } from '../../../../lexicon/lexicons' import Database from '../../../../db' import { ConcurrentWriteError } from '../../../../services/repo' +import { + proxy, + resultPassthru, + authPassthru, + ensureThisPds, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.createRecord({ @@ -28,7 +34,24 @@ export default function (server: Server, ctx: AppContext) { calcPoints: () => 3, }, ], - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.repo.createRecord( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + + ensureThisPds(ctx, auth.credentials.pdsDid) + const { repo, collection, rkey, record, swapCommit, validate } = input.body const did = await ctx.services.account(ctx.db).getDidForActor(repo) diff --git a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts index 99f171e0849..c49738c3710 100644 --- a/packages/pds/src/api/com/atproto/repo/deleteRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/deleteRecord.ts @@ -5,6 +5,7 @@ import AppContext from '../../../../context' import { BadCommitSwapError, BadRecordSwapError } from '../../../../repo' import { CID } from 'multiformats/cid' import { ConcurrentWriteError } from '../../../../services/repo' +import { proxy, authPassthru, ensureThisPds } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.deleteRecord({ @@ -21,7 +22,23 @@ export default function (server: Server, ctx: AppContext) { calcPoints: () => 1, }, ], - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.com.atproto.repo.deleteRecord( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + + ensureThisPds(ctx, auth.credentials.pdsDid) + const { repo, collection, rkey, swapCommit, swapRecord } = input.body const did = await ctx.services.account(ctx.db).getDidForActor(repo) diff --git a/packages/pds/src/api/com/atproto/repo/describeRepo.ts b/packages/pds/src/api/com/atproto/repo/describeRepo.ts index b340314ef77..6e596f9a6a4 100644 --- a/packages/pds/src/api/com/atproto/repo/describeRepo.ts +++ b/packages/pds/src/api/com/atproto/repo/describeRepo.ts @@ -2,16 +2,25 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import * as id from '@atproto/identity' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.describeRepo(async ({ params }) => { const { repo } = params - - const account = await ctx.services.account(ctx.db).getAccount(repo) + const accountService = ctx.services.account(ctx.db) + const account = await accountService.getAccount(repo) if (account === null) { throw new InvalidRequestError(`Could not find user: ${repo}`) } + const proxied = await proxy(ctx, account.pdsDid, async (agent) => { + const result = await agent.api.com.atproto.repo.describeRepo(params) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + let didDoc try { didDoc = await ctx.idResolver.did.ensureResolve(account.did) diff --git a/packages/pds/src/api/com/atproto/repo/getRecord.ts b/packages/pds/src/api/com/atproto/repo/getRecord.ts index 5c99a7226c1..458f97b6540 100644 --- a/packages/pds/src/api/com/atproto/repo/getRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/getRecord.ts @@ -2,35 +2,32 @@ import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' +import { isThisPds, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.getRecord(async ({ params }) => { const { repo, collection, rkey, cid } = params - const did = await ctx.services.account(ctx.db).getDidForActor(repo) + const account = await ctx.services.account(ctx.db).getAccount(repo) // fetch from pds if available, if not then fetch from appview - if (did) { - const uri = AtUri.make(did, collection, rkey) - const record = await ctx.services - .record(ctx.db) - .getRecord(uri, cid || null) - if (!record || record.takedownId !== null) { - throw new InvalidRequestError(`Could not locate record: ${uri}`) - } - return { - encoding: 'application/json', - body: { - uri: uri.toString(), - cid: record.cid, - value: record.value, - }, - } + if (!account || !isThisPds(ctx, account.pdsDid)) { + const res = await ctx.appViewAgent.api.com.atproto.repo.getRecord(params) + return resultPassthru(res) + } + + const uri = AtUri.make(account.did, collection, rkey) + const record = await ctx.services.record(ctx.db).getRecord(uri, cid || null) + if (!record || record.takedownId !== null) { + throw new InvalidRequestError(`Could not locate record: ${uri}`) } - const res = await ctx.appViewAgent.api.com.atproto.repo.getRecord(params) return { encoding: 'application/json', - body: res.data, + body: { + uri: uri.toString(), + cid: record.cid, + value: record.value, + }, } }) } diff --git a/packages/pds/src/api/com/atproto/repo/listRecords.ts b/packages/pds/src/api/com/atproto/repo/listRecords.ts index 8c2669ff010..b60bbf844a0 100644 --- a/packages/pds/src/api/com/atproto/repo/listRecords.ts +++ b/packages/pds/src/api/com/atproto/repo/listRecords.ts @@ -2,6 +2,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.listRecords(async ({ params }) => { @@ -15,13 +16,21 @@ export default function (server: Server, ctx: AppContext) { reverse = false, } = params - const did = await ctx.services.account(ctx.db).getDidForActor(repo) - if (!did) { + const account = await ctx.services.account(ctx.db).getAccount(repo) + if (!account) { throw new InvalidRequestError(`Could not find repo: ${repo}`) } + const proxied = await proxy(ctx, account.pdsDid, async (agent) => { + const result = await agent.api.com.atproto.repo.listRecords(params) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + const records = await ctx.services.record(ctx.db).listRecordsForCollection({ - did, + did: account.did, collection, limit, reverse, diff --git a/packages/pds/src/api/com/atproto/repo/putRecord.ts b/packages/pds/src/api/com/atproto/repo/putRecord.ts index 8fdcc776bb9..35e6e57654d 100644 --- a/packages/pds/src/api/com/atproto/repo/putRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/putRecord.ts @@ -12,6 +12,12 @@ import { PreparedUpdate, } from '../../../../repo' import { ConcurrentWriteError } from '../../../../services/repo' +import { + proxy, + resultPassthru, + authPassthru, + ensureThisPds, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.putRecord({ @@ -28,7 +34,24 @@ export default function (server: Server, ctx: AppContext) { calcPoints: () => 2, }, ], - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.repo.putRecord( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + + ensureThisPds(ctx, auth.credentials.pdsDid) + const { repo, collection, diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index b5a6eaecaef..99d95368b26 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -1,10 +1,34 @@ +import { streamToBytes } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { + authPassthru, + ensureThisPds, + proxy, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.repo.uploadBlob({ auth: ctx.accessVerifierCheckTakedown, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.com.atproto.repo.uploadBlob( + await streamToBytes(input.body), // @TODO proxy streaming + authPassthru(req, true), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + + ensureThisPds(ctx, auth.credentials.pdsDid) + const requester = auth.credentials.did const blob = await ctx.services .repo(ctx.db) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 334c2f2b132..b4201f19f4c 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,7 +1,10 @@ +import { randomInt } from 'node:crypto' import { InvalidRequestError } from '@atproto/xrpc-server' import disposable from 'disposable-email' -import { normalizeAndValidateHandle } from '../../../../handle' import * as plc from '@did-plc/lib' +import { MINUTE } from '@atproto/common' +import { AtprotoData } from '@atproto/identity' +import { normalizeAndValidateHandle } from '../../../../handle' import * as scrypt from '../../../../db/scrypt' import { Server } from '../../../../lexicon' import { InputSchema as CreateAccountInput } from '../../../../lexicon/types/com/atproto/server/createAccount' @@ -9,8 +12,6 @@ import { countAll } from '../../../../db/util' import { UserAlreadyExistsError } from '../../../../services/account' import AppContext from '../../../../context' import Database from '../../../../db' -import { AtprotoData } from '@atproto/identity' -import { MINUTE } from '@atproto/common' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -52,6 +53,7 @@ export default function (server: Server, ctx: AppContext) { const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) + const pds = await assignPds(ctx) const result = await ctx.db.transaction(async (dbTxn) => { const actorTxn = ctx.services.account(dbTxn) @@ -66,7 +68,13 @@ export default function (server: Server, ctx: AppContext) { // Register user before going out to PLC to get a real did try { - await actorTxn.registerUser({ email, handle, did, passwordScrypt }) + await actorTxn.registerUser({ + email, + handle, + did, + pdsId: pds?.id, + passwordScrypt, + }) } catch (err) { if (err instanceof UserAlreadyExistsError) { const got = await actorTxn.getAccount(handle, true) @@ -104,17 +112,24 @@ export default function (server: Server, ctx: AppContext) { .execute() } - const access = ctx.auth.createAccessToken({ did }) - const refresh = ctx.auth.createRefreshToken({ did }) - await ctx.services.auth(dbTxn).grantRefreshToken(refresh.payload, null) + const [accessJwt, refreshJwt] = await Promise.all([ + ctx.auth.createAccessToken({ did, pdsDid: pds?.did }), + ctx.auth.createRefreshToken({ + did, + identityDid: ctx.cfg.service.did, + }), + ]) + const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) + await ctx.services.auth(dbTxn).grantRefreshToken(refreshPayload, null) // Setup repo root + // @TODO contact pds for repo setup, will look like createAccount but bringing own did await repoTxn.createRepo(did, [], now) return { did, - accessJwt: access.jwt, - refreshJwt: refresh.jwt, + accessJwt, + refreshJwt, } }) @@ -244,3 +259,11 @@ const getDidAndPlcOp = async ( return { did: input.did, plcOp: null } } + +// @TODO this implementation is a stub +const assignPds = async (ctx: AppContext) => { + const pdses = await ctx.db.db.selectFrom('pds').selectAll().execute() + if (!pdses.length) return + const pds = pdses.at(randomInt(pdses.length)) + return pds +} diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 6d8d57e471e..97cf4082ca1 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -1,9 +1,9 @@ +import { DAY, MINUTE } from '@atproto/common' import { AuthRequiredError } from '@atproto/xrpc-server' import AppContext from '../../../../context' import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' import { AuthScope } from '../../../../auth' -import { DAY, MINUTE } from '@atproto/common' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createSession({ @@ -55,12 +55,20 @@ export default function (server: Server, ctx: AppContext) { ) } - const access = ctx.auth.createAccessToken({ - did: user.did, - scope: appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, - }) - const refresh = ctx.auth.createRefreshToken({ did: user.did }) - await authService.grantRefreshToken(refresh.payload, appPasswordName) + const [accessJwt, refreshJwt] = await Promise.all([ + ctx.auth.createAccessToken({ + did: user.did, + pdsDid: user.pdsDid, + scope: + appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, + }), + ctx.auth.createRefreshToken({ + did: user.did, + identityDid: ctx.cfg.service.did, + }), + ]) + const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) + await authService.grantRefreshToken(refreshPayload, appPasswordName) return { encoding: 'application/json', @@ -69,8 +77,8 @@ export default function (server: Server, ctx: AppContext) { handle: user.handle, email: user.email, emailConfirmed: !!user.emailConfirmedAt, - accessJwt: access.jwt, - refreshJwt: refresh.jwt, + accessJwt, + refreshJwt, }, } }, diff --git a/packages/pds/src/api/com/atproto/server/deleteSession.ts b/packages/pds/src/api/com/atproto/server/deleteSession.ts index 28610df39d2..e62e70a84e5 100644 --- a/packages/pds/src/api/com/atproto/server/deleteSession.ts +++ b/packages/pds/src/api/com/atproto/server/deleteSession.ts @@ -9,9 +9,12 @@ export default function (server: Server, ctx: AppContext) { if (!token) { throw new AuthRequiredError() } - const refreshToken = ctx.auth.verifyToken(token, [AuthScope.Refresh], { - ignoreExpiration: true, - }) + const refreshToken = await ctx.auth.verifyToken( + token, + [AuthScope.Refresh], + { clockTolerance: Infinity }, // ignore expiration + ) + if (!refreshToken.jti) { throw new Error('Unexpected missing refresh token id') } diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 0fda8ba48a7..ebaed790d29 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -22,10 +22,10 @@ export default function (server: Server, ctx: AppContext) { ) } - const lastRefreshId = ctx.auth.verifyToken( + const { jti: lastRefreshId } = await ctx.auth.verifyToken( ctx.auth.getToken(req) ?? '', [], - ).jti + ) if (!lastRefreshId) { throw new Error('Unexpected missing refresh token id') } @@ -34,19 +34,22 @@ export default function (server: Server, ctx: AppContext) { const authTxn = ctx.services.auth(dbTxn) const rotateRes = await authTxn.rotateRefreshToken(lastRefreshId) if (!rotateRes) return null - const refresh = ctx.auth.createRefreshToken({ + const refreshJwt = await ctx.auth.createRefreshToken({ did: user.did, + identityDid: ctx.cfg.service.did, jti: rotateRes.nextId, }) - await authTxn.grantRefreshToken(refresh.payload, rotateRes.appPassName) - return { refresh, appPassName: rotateRes.appPassName } + const refreshPayload = ctx.auth.decodeRefreshToken(refreshJwt) + await authTxn.grantRefreshToken(refreshPayload, rotateRes.appPassName) + return { refreshJwt, appPassName: rotateRes.appPassName } }) if (res === null) { throw new InvalidRequestError('Token has been revoked', 'ExpiredToken') } - const access = ctx.auth.createAccessToken({ + const accessJwt = await ctx.auth.createAccessToken({ did: user.did, + pdsDid: user.pdsDid, scope: res.appPassName === null ? AuthScope.Access : AuthScope.AppPass, }) @@ -55,8 +58,8 @@ export default function (server: Server, ctx: AppContext) { body: { did: user.did, handle: user.handle, - accessJwt: access.jwt, - refreshJwt: res.refresh.jwt, + accessJwt: accessJwt, + refreshJwt: res.refreshJwt, }, } }, diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts new file mode 100644 index 00000000000..9d46637e5e8 --- /dev/null +++ b/packages/pds/src/api/proxy.ts @@ -0,0 +1,89 @@ +import * as express from 'express' +import AtpAgent from '@atproto/api' +import { Headers, XRPCError } from '@atproto/xrpc' +import { InvalidRequestError, UpstreamFailureError } from '@atproto/xrpc-server' +import AppContext from '../context' + +export const proxy = async ( + ctx: AppContext, + pdsDid: string | null | undefined, + fn: (agent: AtpAgent) => Promise, +): Promise => { + if (isThisPds(ctx, pdsDid)) { + return null // skip proxying + } + const accountService = ctx.services.account(ctx.db) + const pds = pdsDid && (await accountService.getPds(pdsDid)) + if (!pds) { + throw new UpstreamFailureError('unknown pds') + } + // @TODO reuse agents + const agent = new AtpAgent({ service: `https://${pds.host}` }) + try { + return await fn(agent) + } catch (err) { + // @TODO may need to pass through special lexicon errors + if ( + err instanceof XRPCError && + err.status === 403 && + err.error === 'AccountNotFound' + ) { + // instruct client to refresh token during potential account migration + throw new InvalidRequestError( + 'Token audience is out of date', + 'ExpiredToken', + ) + } + throw err + } +} + +export const isThisPds = ( + ctx: AppContext, + pdsDid: string | null | undefined, +) => { + return !pdsDid || pdsDid === ctx.cfg.service.did +} + +// @NOTE on the identity service this serves a 400 w/ ExpiredToken to prompt a refresh flow from the client. +// but on our other PDSes the same case should be a 403 w/ AccountNotFound, assuming their access token verifies. +export const ensureThisPds = (ctx: AppContext, pdsDid: string | null) => { + if (!isThisPds(ctx, pdsDid)) { + // instruct client to refresh token during potential account migration + throw new InvalidRequestError( + 'Token audience is out of date', + 'ExpiredToken', + ) + } +} + +export const resultPassthru = (result: { headers: Headers; data: T }) => { + // @TODO pass through any headers that we always want to forward along + return { + encoding: 'application/json' as const, + body: result.data, + } +} + +// Output designed to passed as second arg to AtpAgent methods. +// The encoding field here is a quirk of the AtpAgent. +export function authPassthru( + req: express.Request, + withEncoding?: false, +): { headers: { authorization: string }; encoding: undefined } | undefined + +export function authPassthru( + req: express.Request, + withEncoding: true, +): + | { headers: { authorization: string }; encoding: 'application/json' } + | undefined + +export function authPassthru(req: express.Request, withEncoding?: boolean) { + if (req.headers.authorization) { + return { + headers: { authorization: req.headers.authorization }, + encoding: withEncoding ? 'application/json' : undefined, + } + } +} diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 6d75f1fd920..ae8541f8191 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -1,16 +1,22 @@ +import * as assert from 'node:assert' +import { KeyObject, createPrivateKey, createSecretKey } from 'node:crypto' +import express from 'express' +import KeyEncoder from 'key-encoder' +import * as ui8 from 'uint8arrays' +import * as jose from 'jose' import * as crypto from '@atproto/crypto' import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' -import * as ui8 from 'uint8arrays' -import express from 'express' -import * as jwt from 'jsonwebtoken' import AppContext from './context' import { softDeleted } from './db/util' const BEARER = 'Bearer ' const BASIC = 'Basic ' +const SECP256K1_JWT = 'ES256K' +const HMACSHA256_JWT = 'HS256' export type ServerAuthOpts = { jwtSecret: string + jwtSigningKey?: crypto.Secp256k1Keypair adminPass: string moderatorPass?: string triagePass?: string @@ -27,92 +33,140 @@ export type AuthToken = { scope: AuthScope sub: string exp: number + aud?: string } -export type RefreshToken = AuthToken & { jti: string } +export type RefreshToken = AuthToken & { jti: string; aud: string } export class ServerAuth { - private _secret: string + private _signingSecret: KeyObject + private _signingKey?: KeyObject private _adminPass: string private _moderatorPass?: string private _triagePass?: string - constructor(opts: ServerAuthOpts) { - this._secret = opts.jwtSecret + constructor(opts: { + signingSecret: KeyObject + signingKey?: KeyObject + adminPass: string + moderatorPass?: string + triagePass?: string + }) { + this._signingSecret = opts.signingSecret + this._signingKey = opts.signingKey this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass this._triagePass = opts.triagePass } - createAccessToken(opts: { + static async create(opts: ServerAuthOpts): Promise { + const signingSecret = createSecretKey(Buffer.from(opts.jwtSecret)) + const signingKey = opts.jwtSigningKey + ? await createPrivateKeyObject(opts.jwtSigningKey) + : undefined + const adminPass = opts.adminPass + const moderatorPass = opts.moderatorPass + const triagePass = opts.triagePass + return new ServerAuth({ + signingSecret, + signingKey, + adminPass, + moderatorPass, + triagePass, + }) + } + + async createAccessToken(opts: { did: string + pdsDid?: string | null scope?: AuthScope expiresIn?: string | number }) { const { did, scope = AuthScope.Access, expiresIn = '120mins' } = opts - const payload = { - scope, - sub: did, + + const signer = new jose.SignJWT({ scope }) + .setSubject(did) + .setIssuedAt() + .setExpirationTime(expiresIn) + if (opts.pdsDid) { + signer.setAudience(opts.pdsDid) } - return { - payload: payload as AuthToken, // exp set by sign() - jwt: jwt.sign(payload, this._secret, { - expiresIn: expiresIn, - mutatePayload: true, - }), + + if (this._signingKey) { + const key = this._signingKey + return signer.setProtectedHeader({ alg: SECP256K1_JWT }).sign(key) + } else { + const key = this._signingSecret + return signer.setProtectedHeader({ alg: HMACSHA256_JWT }).sign(key) } } - createRefreshToken(opts: { + async createRefreshToken(opts: { did: string + identityDid: string jti?: string expiresIn?: string | number }) { const { did, jti = getRefreshTokenId(), expiresIn = '90days' } = opts - const payload = { - scope: AuthScope.Refresh, - sub: did, - jti, - } - return { - payload: payload as RefreshToken, // exp set by sign() - jwt: jwt.sign(payload, this._secret, { - expiresIn: expiresIn, - mutatePayload: true, - }), + + const signer = new jose.SignJWT({ scope: AuthScope.Refresh }) + .setSubject(did) + .setAudience(opts.identityDid) + .setJti(jti) + .setIssuedAt() + .setExpirationTime(expiresIn) + + if (this._signingKey) { + const key = await this._signingKey + return signer.setProtectedHeader({ alg: SECP256K1_JWT }).sign(key) + } else { + const key = this._signingSecret + return signer.setProtectedHeader({ alg: HMACSHA256_JWT }).sign(key) } } - getCredentials( + // @NOTE unsafe for verification, should only be used w/ direct output from createRefreshToken() + decodeRefreshToken(jwt: string) { + const token = jose.decodeJwt(jwt) + assert.ok(token.scope === AuthScope.Refresh, 'not a refresh token') + return token as RefreshToken + } + + async getCredentials( req: express.Request, scopes = [AuthScope.Access], - ): { did: string; scope: AuthScope } | null { + ): Promise<{ did: string; scope: AuthScope; audience?: string } | null> { const token = this.getToken(req) if (!token) return null - const payload = this.verifyToken(token, scopes) - const sub = payload.sub + const payload = await this.verifyToken(token, scopes) + const { sub, aud, scope } = payload if (typeof sub !== 'string' || !sub.startsWith('did:')) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } - return { did: sub, scope: payload.scope } + if ( + aud !== undefined && + (typeof aud !== 'string' || !aud.startsWith('did:')) + ) { + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } + return { + did: sub, + audience: aud, + scope: scope as AuthScope, + } } - getCredentialsOrThrow( + async getCredentialsOrThrow( req: express.Request, scopes: AuthScope[], - ): { did: string; scope: AuthScope } { - const creds = this.getCredentials(req, scopes) + ): Promise<{ did: string; scope: AuthScope; audience?: string }> { + const creds = await this.getCredentials(req, scopes) if (creds === null) { throw new AuthRequiredError(undefined, 'AuthMissing') } return creds } - verifyUser(req: express.Request, did: string, scopes: AuthScope[]): boolean { - const authorized = this.getCredentials(req, scopes) - return authorized !== null && authorized.did === did - } - verifyRole(req: express.Request) { const parsed = parseBasicAuth(req.headers.authorization || '') const { Missing, Valid, Invalid } = AuthStatus @@ -138,22 +192,23 @@ export class ServerAuth { return header.slice(BEARER.length) } - verifyToken( + async verifyToken( token: string, scopes: AuthScope[], - options?: jwt.VerifyOptions, - ): jwt.JwtPayload { + options?: jose.JWTVerifyOptions, + ): Promise { + const header = jose.decodeProtectedHeader(token) + let result: jose.JWTVerifyResult try { - const payload = jwt.verify(token, this._secret, options) - if (typeof payload === 'string' || 'signature' in payload) { - throw new InvalidRequestError('Malformed token', 'InvalidToken') - } - if (scopes.length > 0 && !scopes.includes(payload.scope)) { - throw new InvalidRequestError('Bad token scope', 'InvalidToken') + if (header.alg === SECP256K1_JWT && this._signingKey) { + const key = await this._signingKey + result = await jose.jwtVerify(token, key, options) + } else { + const key = this._signingSecret + result = await jose.jwtVerify(token, key, options) } - return payload } catch (err) { - if (err instanceof jwt.TokenExpiredError) { + if (err?.['code'] === 'ERR_JWT_EXPIRED') { throw new InvalidRequestError('Token has expired', 'ExpiredToken') } throw new InvalidRequestError( @@ -161,6 +216,10 @@ export class ServerAuth { 'InvalidToken', ) } + if (scopes.length > 0 && !scopes.includes(result.payload.scope as any)) { + throw new InvalidRequestError('Bad token scope', 'InvalidToken') + } + return result.payload } toString(): string { @@ -187,7 +246,7 @@ export const parseBasicAuth = ( export const accessVerifier = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [ + const creds = await auth.getCredentialsOrThrow(ctx.req, [ AuthScope.Access, AuthScope.AppPass, ]) @@ -200,7 +259,7 @@ export const accessVerifier = export const accessVerifierNotAppPassword = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [AuthScope.Access]) + const creds = await auth.getCredentialsOrThrow(ctx.req, [AuthScope.Access]) return { credentials: creds, artifacts: auth.getToken(ctx.req), @@ -210,7 +269,7 @@ export const accessVerifierNotAppPassword = export const accessVerifierCheckTakedown = (auth: ServerAuth, { db, services }: AppContext) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [ + const creds = await auth.getCredentialsOrThrow(ctx.req, [ AuthScope.Access, AuthScope.AppPass, ]) @@ -222,7 +281,7 @@ export const accessVerifierCheckTakedown = ) } return { - credentials: creds, + credentials: { ...creds, pdsDid: actor.pdsDid }, artifacts: auth.getToken(ctx.req), } } @@ -298,7 +357,7 @@ export const isUserOrAdmin = ( export const refreshVerifier = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { - const creds = auth.getCredentialsOrThrow(ctx.req, [AuthScope.Refresh]) + const creds = await auth.getCredentialsOrThrow(ctx.req, [AuthScope.Refresh]) return { credentials: creds, artifacts: auth.getToken(ctx.req), @@ -324,3 +383,13 @@ export enum AuthStatus { Invalid, Missing, } + +const createPrivateKeyObject = async ( + privateKey: crypto.Secp256k1Keypair, +): Promise => { + const raw = await privateKey.export() + const key = keyEncoder.encodePrivate(ui8.toString(raw, 'hex'), 'raw', 'pem') + return createPrivateKey({ format: 'pem', key }) +} + +const keyEncoder = new KeyEncoder('secp256k1') diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 170e26d5976..1e3d84194b4 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -74,6 +74,9 @@ export const readEnv = (): ServerEnvironment => { // secrets jwtSecret: envStr('PDS_JWT_SECRET'), + jwtSigningKeyK256PrivateKeyHex: envStr( + 'PDS_JWT_SIGNING_KEY_K256_PRIVATE_KEY_HEX', + ), adminPassword: envStr('PDS_ADMIN_PASSWORD'), moderatorPassword: envStr('PDS_MODERATOR_PASSWORD'), triagePassword: envStr('PDS_TRIAGE_PASSWORD'), @@ -163,6 +166,7 @@ export type ServerEnvironment = { // secrets jwtSecret?: string + jwtSigningKeyK256PrivateKeyHex?: string adminPassword?: string moderatorPassword?: string triagePassword?: string diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index f0f876f1ccc..12acf2c132b 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -35,6 +35,14 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { throw new Error('Must configure plc rotation key') } + let jwtSigningKey: ServerSecrets['jwtSigningKey'] + if (env.jwtSigningKeyK256PrivateKeyHex) { + jwtSigningKey = { + provider: 'memory', + privateKeyHex: env.jwtSigningKeyK256PrivateKeyHex, + } + } + if (!env.jwtSecret) { throw new Error('Must provide a JWT secret') } @@ -45,6 +53,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { return { jwtSecret: env.jwtSecret, + jwtSigningKey, adminPassword: env.adminPassword, moderatorPassword: env.moderatorPassword ?? env.adminPassword, triagePassword: @@ -56,6 +65,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { export type ServerSecrets = { jwtSecret: string + jwtSigningKey?: SigningKeyMemory adminPassword: string moderatorPassword: string triagePassword: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 328b61893a1..ac511b0439d 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -152,7 +152,15 @@ export class AppContext { const appViewAgent = new AtpAgent({ service: cfg.bskyAppView.url }) - const auth = new ServerAuth({ + const jwtSigningKey = + secrets.jwtSigningKey && + (await crypto.Secp256k1Keypair.import( + secrets.jwtSigningKey.privateKeyHex, + { exportable: true }, + )) + + const auth = await ServerAuth.create({ + jwtSigningKey, jwtSecret: secrets.jwtSecret, adminPass: secrets.adminPassword, moderatorPass: secrets.moderatorPassword, diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 26159418206..171c24d455d 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely' import * as userAccount from './tables/user-account' +import * as pds from './tables/pds' import * as userPref from './tables/user-pref' import * as didHandle from './tables/did-handle' import * as repoRoot from './tables/repo-root' @@ -21,6 +22,7 @@ import * as runtimeFlag from './tables/runtime-flag' export type DatabaseSchemaType = appMigration.PartialDB & runtimeFlag.PartialDB & userAccount.PartialDB & + pds.PartialDB & userPref.PartialDB & didHandle.PartialDB & refreshToken.PartialDB & diff --git a/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts new file mode 100644 index 00000000000..c293b0694c9 --- /dev/null +++ b/packages/pds/src/db/migrations/20231004T040354739Z-user-account-pds.ts @@ -0,0 +1,25 @@ +import { Kysely } from 'kysely' +import { Dialect } from '..' + +export async function up(db: Kysely, dialect: Dialect): Promise { + const pdsBuilder = + dialect === 'pg' + ? db.schema + .createTable('pds') + .addColumn('id', 'serial', (col) => col.primaryKey()) + : db.schema + .createTable('pds') + .addColumn('id', 'integer', (col) => col.autoIncrement().primaryKey()) + await pdsBuilder + .addColumn('did', 'varchar', (col) => col.notNull()) + .addColumn('host', 'varchar', (col) => col.notNull()) + .execute() + await db.schema + .alterTable('user_account') + .addColumn('pdsId', 'integer', (col) => col.references('pds.id')) + .execute() +} +export async function down(db: Kysely): Promise { + await db.schema.alterTable('user_account').dropColumn('pdsId').execute() + await db.schema.dropTable('pds').execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index 9aead0d7012..05a22d816ca 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -6,3 +6,4 @@ export * as _20230613T164932261Z from './20230613T164932261Z-init' export * as _20230914T014727199Z from './20230914T014727199Z-repo-v3' export * as _20230926T195532354Z from './20230926T195532354Z-email-tokens' export * as _20230929T213219699Z from './20230929T213219699Z-takedown-id-as-int' +export * as _20231004T040354739Z from './20231004T040354739Z-user-account-pds' diff --git a/packages/pds/src/db/tables/pds.ts b/packages/pds/src/db/tables/pds.ts new file mode 100644 index 00000000000..b2f102366cc --- /dev/null +++ b/packages/pds/src/db/tables/pds.ts @@ -0,0 +1,11 @@ +import { GeneratedAlways } from 'kysely' + +export interface Pds { + id: GeneratedAlways + did: string + host: string +} + +export const tableName = 'pds' + +export type PartialDB = { [tableName]: Pds } diff --git a/packages/pds/src/db/tables/user-account.ts b/packages/pds/src/db/tables/user-account.ts index 808663ca468..3b45b089a42 100644 --- a/packages/pds/src/db/tables/user-account.ts +++ b/packages/pds/src/db/tables/user-account.ts @@ -8,6 +8,7 @@ export interface UserAccount { emailConfirmedAt: string | null invitesDisabled: Generated<0 | 1> inviteNote: string | null + pdsId: number | null } export type UserAccountEntry = Selectable diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 0732b6efd7a..cc9e1555895 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -185,5 +185,3 @@ export class PDS { } export default PDS - -// @TODO remove me: just producing a diff to start a feature branch. diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index a4277e41669..fe17a4e8c0a 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -1,7 +1,7 @@ import pino from 'pino' import pinoHttp from 'pino-http' import { subsystemLogger } from '@atproto/common' -import * as jwt from 'jsonwebtoken' +import * as jose from 'jose' import { parseBasicAuth } from './auth' export const dbLogger = subsystemLogger('pds:db') @@ -22,7 +22,7 @@ export const loggerMiddleware = pinoHttp({ let auth: string | undefined = undefined if (authHeader.startsWith('Bearer ')) { const token = authHeader.slice('Bearer '.length) - const sub = jwt.decode(token)?.sub + const { sub } = jose.decodeJwt(token) if (sub) { auth = 'Bearer ' + sub } else { diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 9a6910d0e4f..ea1ae85aeaa 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -22,15 +22,17 @@ export class AccountService { return (db: Database) => new AccountService(db) } + // @TODO decouple account from repo_root, move takedownId. async getAccount( handleOrDid: string, includeSoftDeleted = false, - ): Promise<(UserAccountEntry & DidHandle & RepoRoot) | null> { + ): Promise { const { ref } = this.db.db.dynamic const result = await this.db.db .selectFrom('user_account') .innerJoin('did_handle', 'did_handle.did', 'user_account.did') .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .leftJoin('pds', 'pds.id', 'user_account.pdsId') .if(!includeSoftDeleted, (qb) => qb.where(notSoftDeletedClause(ref('repo_root'))), ) @@ -47,6 +49,7 @@ export class AccountService { ) } }) + .select(['pds.did as pdsDid']) .selectAll('user_account') .selectAll('did_handle') .selectAll('repo_root') @@ -68,16 +71,18 @@ export class AccountService { async getAccountByEmail( email: string, includeSoftDeleted = false, - ): Promise<(UserAccountEntry & DidHandle & RepoRoot) | null> { + ): Promise { const { ref } = this.db.db.dynamic const found = await this.db.db .selectFrom('user_account') .innerJoin('did_handle', 'did_handle.did', 'user_account.did') .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .leftJoin('pds', 'pds.id', 'user_account.pdsId') .if(!includeSoftDeleted, (qb) => qb.where(notSoftDeletedClause(ref('repo_root'))), ) .where('email', '=', email.toLowerCase()) + .select(['pds.did as pdsDid']) .selectAll('user_account') .selectAll('did_handle') .selectAll('repo_root') @@ -113,16 +118,18 @@ export class AccountService { email: string handle: string did: string + pdsId?: number passwordScrypt: string }) { this.db.assertTransaction() - const { email, handle, did, passwordScrypt } = opts + const { email, handle, did, pdsId, passwordScrypt } = opts log.debug({ handle, email }, 'registering user') const registerUserAccnt = this.db.db .insertInto('user_account') .values({ email: email.toLowerCase(), did, + pdsId, passwordScrypt, createdAt: new Date().toISOString(), }) @@ -588,6 +595,15 @@ export class AccountService { await this.db.db.insertInto('user_pref').values(putPrefs).execute() } } + + // @TODO cache w/ in-mem lookup + async getPds(pdsDid: string) { + return await this.db.db + .selectFrom('pds') + .where('did', '=', pdsDid) + .selectAll() + .executeTakeFirst() + } } export type UserPreference = Record & { $type: string } @@ -623,3 +639,7 @@ const matchNamespace = (namespace: string, fullname: string) => { } export type HandleSequenceToken = { did: string; handle: string } + +type AccountInfo = UserAccountEntry & + DidHandle & + RepoRoot & { pdsDid: string | null } diff --git a/packages/pds/tests/app-passwords.test.ts b/packages/pds/tests/app-passwords.test.ts index c8e1309dda8..dc657da9078 100644 --- a/packages/pds/tests/app-passwords.test.ts +++ b/packages/pds/tests/app-passwords.test.ts @@ -1,6 +1,6 @@ +import * as jose from 'jose' import { TestNetworkNoAppView } from '@atproto/dev-env' import AtpAgent from '@atproto/api' -import * as jwt from 'jsonwebtoken' describe('app_passwords', () => { let network: TestNetworkNoAppView @@ -44,9 +44,7 @@ describe('app_passwords', () => { }) it('creates an access token for an app with a restricted scope', () => { - const decoded = jwt.decode(appAgent.session?.accessJwt ?? '', { - json: true, - }) + const decoded = jose.decodeJwt(appAgent.session?.accessJwt ?? '') expect(decoded?.scope).toEqual('com.atproto.appPass') }) @@ -66,7 +64,7 @@ describe('app_passwords', () => { const attempt = appAgent.api.com.atproto.server.createAppPassword({ name: 'another-one', }) - await expect(attempt).rejects.toThrow('Token could not be verified') + await expect(attempt).rejects.toThrow('Bad token scope') }) it('persists scope across refreshes', async () => { @@ -93,15 +91,13 @@ describe('app_passwords', () => { ) const attempt = appAgent.api.com.atproto.server.createAppPassword( - { - name: 'another-one', - }, + { name: 'another-one' }, { encoding: 'application/json', headers: { authorization: `Bearer ${session.data.accessJwt}` }, }, ) - await expect(attempt).rejects.toThrow('Token could not be verified') + await expect(attempt).rejects.toThrow('Bad token scope') }) it('lists available app-specific passwords', async () => { diff --git a/packages/pds/tests/auth.test.ts b/packages/pds/tests/auth.test.ts index d94eebf17e1..9046562eafc 100644 --- a/packages/pds/tests/auth.test.ts +++ b/packages/pds/tests/auth.test.ts @@ -1,4 +1,4 @@ -import * as jwt from 'jsonwebtoken' +import * as jose from 'jose' import AtpAgent from '@atproto/api' import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' @@ -159,9 +159,9 @@ describe('auth', () => { const refresh1 = await refreshSession(account.refreshJwt) const refresh2 = await refreshSession(account.refreshJwt) - const token0 = jwt.decode(account.refreshJwt, { json: true }) - const token1 = jwt.decode(refresh1.refreshJwt, { json: true }) - const token2 = jwt.decode(refresh2.refreshJwt, { json: true }) + const token0 = jose.decodeJwt(account.refreshJwt) + const token1 = jose.decodeJwt(refresh1.refreshJwt) + const token2 = jose.decodeJwt(refresh2.refreshJwt) expect(typeof token1?.jti).toEqual('string') expect(token1?.jti).toEqual(token2?.jti) @@ -177,7 +177,7 @@ describe('auth', () => { password: 'password', }) await refreshSession(account.refreshJwt) - const token = jwt.decode(account.refreshJwt, { json: true }) + const token = jose.decodeJwt(account.refreshJwt) // Update expiration (i.e. grace period) to end immediately const refreshUpdated = await db.db @@ -219,9 +219,7 @@ describe('auth', () => { password: 'password', }) const refreshWithAccess = refreshSession(account.accessJwt) - await expect(refreshWithAccess).rejects.toThrow( - 'Token could not be verified', - ) + await expect(refreshWithAccess).rejects.toThrow('Bad token scope') }) it('expired refresh token cannot be used to refresh a session.', async () => { @@ -231,10 +229,13 @@ describe('auth', () => { email: 'holga@test.com', password: 'password', }) - const refresh = auth.createRefreshToken({ did: account.did, expiresIn: -1 }) - const refreshExpired = refreshSession(refresh.jwt) + const refreshJwt = await auth.createRefreshToken({ + did: account.did, + expiresIn: -1, + }) + const refreshExpired = refreshSession(refreshJwt) await expect(refreshExpired).rejects.toThrow('Token has expired') - await deleteSession(refresh.jwt) // No problem revoking an expired token + await deleteSession(refreshJwt) // No problem revoking an expired token }) it('actor takedown disallows fresh session.', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c899913626..654ab7c219e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,9 +540,12 @@ importers: ioredis: specifier: ^5.3.2 version: 5.3.2 - jsonwebtoken: - specifier: ^8.5.1 - version: 8.5.1 + jose: + specifier: ^4.15.2 + version: 4.15.2 + key-encoder: + specifier: ^2.0.3 + version: 2.0.3 kysely: specifier: ^0.22.0 version: 0.22.0 @@ -604,9 +607,6 @@ importers: '@types/express-serve-static-core': specifier: ^4.17.36 version: 4.17.36 - '@types/jsonwebtoken': - specifier: ^8.5.9 - version: 8.5.9 '@types/nodemailer': specifier: ^6.4.6 version: 6.4.6 @@ -5381,12 +5381,6 @@ packages: resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} dev: true - /@types/jsonwebtoken@8.5.9: - resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} - dependencies: - '@types/node': 18.17.8 - dev: true - /@types/mime@1.3.2: resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==} dev: true @@ -6101,10 +6095,6 @@ packages: node-int64: 0.4.0 dev: true - /buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - dev: false - /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -6713,12 +6703,6 @@ packages: engines: {node: '>=10'} dev: true - /ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - dependencies: - safe-buffer: 5.2.1 - dev: false - /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -8732,6 +8716,10 @@ packages: - ts-node dev: true + /jose@4.15.2: + resolution: {integrity: sha512-IY73F228OXRl9ar3jJagh7Vnuhj/GzBunPiZP13K0lOl7Am9SoWW3kEzq3MCllJMTtZqHTiDXQvoRd4U95aU6A==} + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -8815,37 +8803,6 @@ packages: graceful-fs: 4.2.11 dev: true - /jsonwebtoken@8.5.1: - resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} - engines: {node: '>=4', npm: '>=1.4.28'} - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 5.7.2 - dev: false - - /jwa@1.4.1: - resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - dev: false - - /jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - dependencies: - jwa: 1.4.1 - safe-buffer: 5.2.1 - dev: false - /key-encoder@2.0.3: resolution: {integrity: sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg==} dependencies: @@ -8937,34 +8894,10 @@ packages: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} dev: false - /lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - dev: false - /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} dev: false - /lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - dev: false - - /lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - dev: false - - /lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - dev: false - - /lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - dev: false - - /lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - dev: false - /lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} dev: false @@ -8973,10 +8906,6 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true - /lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - dev: false - /lodash.pick@4.4.0: resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==} dev: false From 7647abcc1d9554384437aa8c760134f455ad5ec8 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Fri, 27 Oct 2023 17:30:35 -0400 Subject: [PATCH 024/135] Account creation flow between PDS and entryway (#1744) * pull prospective lexicons for byo-plc-op * accept plc op on create account * impl server.getSigningKey * tidy entryway tests for updated account creation * note * fix/tidy * rename getSigningKey to reserveSigningKey * remove --- .../api/com/atproto/server/createAccount.ts | 102 +++++++++++++++--- .../api/com/atproto/server/deleteAccount.ts | 1 + .../pds/src/api/com/atproto/server/index.ts | 2 + .../com/atproto/server/reserveSigningKey.ts | 15 +++ packages/pds/src/api/proxy.ts | 14 ++- packages/pds/tests/entryway.test.ts | 76 +++++++------ 6 files changed, 149 insertions(+), 61 deletions(-) create mode 100644 packages/pds/src/api/com/atproto/server/reserveSigningKey.ts diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 1a2d8737e02..c0f9ef7d15c 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,8 +1,9 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' import disposable from 'disposable-email' +import AtpAgent from '@atproto/api' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { MINUTE, cborDecode, cborEncode, check } from '@atproto/common' +import { AtprotoData, ensureAtpDocument } from '@atproto/identity' import * as plc from '@did-plc/lib' -import { MINUTE } from '@atproto/common' -import { AtprotoData } from '@atproto/identity' import { normalizeAndValidateHandle } from '../../../../handle' import * as scrypt from '../../../../db/scrypt' import { Server } from '../../../../lexicon' @@ -11,6 +12,7 @@ import { countAll } from '../../../../db/util' import { UserAlreadyExistsError } from '../../../../services/account' import AppContext from '../../../../context' import Database from '../../../../db' +import { getPdsEndpoint, isThisPds } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -20,8 +22,11 @@ export default function (server: Server, ctx: AppContext) { }, handler: async ({ input, req }) => { const { email, password, inviteCode } = input.body - if (input.body.plcOp) { - throw new InvalidRequestError('Unsupported input: "plcOp"') + + if (!ctx.cfg.service.isEntryway && !input.body.did && !input.body.plcOp) { + throw new InvalidRequestError( + 'non-entryway pds requires bringing a DID or PLC operation', + ) } if (ctx.cfg.invites.required && !inviteCode) { @@ -51,11 +56,11 @@ export default function (server: Server, ctx: AppContext) { // determine the did & any plc ops we need to send // if the provided did document is poorly setup, we throw - const { did, plcOp } = await getDidAndPlcOp(ctx, handle, input.body) + const pds = await assignPds(ctx) + const { did, plcOp } = await getDidAndPlcOp(ctx, pds, handle, input.body) const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) - const pds = await assignPds(ctx) const result = await ctx.db.transaction(async (dbTxn) => { const actorTxn = ctx.services.account(dbTxn) @@ -90,7 +95,7 @@ export default function (server: Server, ctx: AppContext) { } // Generate a real did with PLC - if (plcOp) { + if (plcOp && isThisPds(ctx, pds?.did)) { try { await ctx.plcClient.sendOperation(did, plcOp) } catch (err) { @@ -122,9 +127,17 @@ export default function (server: Server, ctx: AppContext) { appPasswordName: null, }) - // Setup repo root - // @TODO contact pds for repo setup, will look like createAccount but bringing own did - await repoTxn.createRepo(did, [], now) + if (!pds || isThisPds(ctx, pds.did)) { + // Setup repo root + await repoTxn.createRepo(did, [], now) + } else { + const agent = new AtpAgent({ service: getPdsEndpoint(pds.host) }) + await agent.com.atproto.server.createAccount({ + ...input.body, + did, + plcOp: plcOp ? cborEncode(plcOp) : undefined, + }) + } return { did, @@ -189,12 +202,59 @@ export const ensureCodeIsAvailable = async ( const getDidAndPlcOp = async ( ctx: AppContext, + pds: { host: string } | undefined, handle: string, input: CreateAccountInput, ): Promise<{ did: string plcOp: plc.Operation | null }> => { + const pdsEndpoint = pds ? getPdsEndpoint(pds.host) : ctx.cfg.service.publicUrl + const pdsSigningKey = pds + ? await reserveSigningKey(pds.host) + : ctx.repoSigningKey.did() + + // if the user brings their own PLC op then we validate it then submit it to PLC on their behalf + if (input.plcOp) { + let atpData: AtprotoData + let plcOp: plc.Operation + try { + plcOp = check.assure(plc.def.operation, cborDecode(input.plcOp)) + const did = await plc.didForCreateOp(plcOp) + const docData = await plc.assureValidCreationOp(did, plcOp) + const doc = plc.formatDidDoc(docData) + atpData = ensureAtpDocument(doc) + } catch (err) { + throw new InvalidRequestError( + 'could not validate PLC creation operation', + 'InvalidPlcOp', + ) + } + if (input.did && input.did !== atpData.did) { + throw new InvalidRequestError( + 'the DID does not match the PLC creation operation', + 'IncompatiblePlcOp', + ) + } + if (atpData.handle !== handle) { + throw new InvalidRequestError( + 'provided handle does not match PLC operation handle', + 'IncompatiblePlcOp', + ) + } else if (atpData.pds !== pdsEndpoint) { + throw new InvalidRequestError( + 'PLC operation pds endpoint does not match service endpoint', + 'IncompatiblePlcOp', + ) + } else if (atpData.signingKey !== pdsSigningKey) { + throw new InvalidRequestError( + 'PLC operation signing key does not match service signing key', + 'IncompatiblePlcOp', + ) + } + return { did: atpData.did, plcOp } + } + // if the user is not bringing a DID, then we format a create op for PLC // but we don't send until we ensure the username & email are available if (!input.did) { @@ -206,10 +266,10 @@ const getDidAndPlcOp = async ( rotationKeys.unshift(input.recoveryKey) } const plcCreate = await plc.createOp({ - signingKey: ctx.repoSigningKey.did(), + signingKey: pdsSigningKey, rotationKeys, handle, - pds: ctx.cfg.service.publicUrl, + pds: pdsEndpoint, signer: ctx.plcRotationKey, }) return { @@ -226,7 +286,7 @@ const getDidAndPlcOp = async ( atpData = await ctx.idResolver.did.resolveAtprotoData(input.did) } catch (err) { throw new InvalidRequestError( - `could not resolve valid DID document :${input.did}`, + `could not resolve valid DID document: ${input.did}`, 'UnresolvableDid', ) } @@ -235,19 +295,20 @@ const getDidAndPlcOp = async ( 'provided handle does not match DID document handle', 'IncompatibleDidDoc', ) - } else if (atpData.pds !== ctx.cfg.service.publicUrl) { + } else if (atpData.pds !== pdsEndpoint) { throw new InvalidRequestError( 'DID document pds endpoint does not match service endpoint', 'IncompatibleDidDoc', ) - } else if (atpData.signingKey !== ctx.repoSigningKey.did()) { + } else if (atpData.signingKey !== pdsSigningKey) { throw new InvalidRequestError( 'DID document signing key does not match service signing key', 'IncompatibleDidDoc', ) } - if (input.did.startsWith('did:plc')) { + // non-entryway pds doesn't require matching plc rotation key, will be handled by its entryway + if (input.did.startsWith('did:plc') && ctx.cfg.service.isEntryway) { const data = await ctx.plcClient.getDocumentData(input.did) if (!data.rotationKeys.includes(ctx.plcRotationKey.did())) { throw new InvalidRequestError( @@ -262,12 +323,19 @@ const getDidAndPlcOp = async ( // @TODO this implementation is a stub const assignPds = async (ctx: AppContext) => { + if (!ctx.cfg.service.isEntryway) return const pdses = await ctx.db.db.selectFrom('pds').selectAll().execute() const idx = randomIndexByWeight(pdses.map((pds) => pds.weight)) if (idx === -1) return return pdses.at(idx) } +const reserveSigningKey = async (host: string) => { + const agent = new AtpAgent({ service: getPdsEndpoint(host) }) + const result = await agent.com.atproto.server.reserveSigningKey() + return result.data.signingKey +} + const randomIndexByWeight = (weights) => { let sum = 0 const cumulative = weights.map((weight) => { diff --git a/packages/pds/src/api/com/atproto/server/deleteAccount.ts b/packages/pds/src/api/com/atproto/server/deleteAccount.ts index 4d12edb1b32..969433ee3c1 100644 --- a/packages/pds/src/api/com/atproto/server/deleteAccount.ts +++ b/packages/pds/src/api/com/atproto/server/deleteAccount.ts @@ -6,6 +6,7 @@ import { MINUTE } from '@atproto/common' const REASON_ACCT_DELETION = 'ACCOUNT DELETION' +// @TODO negotiate account deletions between pds and entryway export default function (server: Server, ctx: AppContext) { server.com.atproto.server.deleteAccount({ rateLimit: { diff --git a/packages/pds/src/api/com/atproto/server/index.ts b/packages/pds/src/api/com/atproto/server/index.ts index 210d0f45461..f5ab1245c1c 100644 --- a/packages/pds/src/api/com/atproto/server/index.ts +++ b/packages/pds/src/api/com/atproto/server/index.ts @@ -7,6 +7,7 @@ import createAccount from './createAccount' import createInviteCode from './createInviteCode' import createInviteCodes from './createInviteCodes' import getAccountInviteCodes from './getAccountInviteCodes' +import reserveSigningKey from './reserveSigningKey' import requestDelete from './requestAccountDelete' import deleteAccount from './deleteAccount' @@ -35,6 +36,7 @@ export default function (server: Server, ctx: AppContext) { createInviteCode(server, ctx) createInviteCodes(server, ctx) getAccountInviteCodes(server, ctx) + reserveSigningKey(server, ctx) requestDelete(server, ctx) deleteAccount(server, ctx) requestPasswordReset(server, ctx) diff --git a/packages/pds/src/api/com/atproto/server/reserveSigningKey.ts b/packages/pds/src/api/com/atproto/server/reserveSigningKey.ts new file mode 100644 index 00000000000..dad77a70754 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/reserveSigningKey.ts @@ -0,0 +1,15 @@ +import AppContext from '../../../../context' +import { Server } from '../../../../lexicon' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.reserveSigningKey({ + handler: async () => { + return { + encoding: 'application/json', + body: { + signingKey: ctx.repoSigningKey.did(), + }, + } + }, + }) +} diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 573c4abea49..726b2118a04 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -22,11 +22,7 @@ export const proxy = async ( throw new UpstreamFailureError('unknown pds') } // @TODO reuse agents - const service = new URL(`https://${pds.host}`) - if (service.hostname === 'localhost') { - service.protocol = 'http:' - } - const agent = new AtpAgent({ service }) + const agent = new AtpAgent({ service: getPdsEndpoint(pds.host) }) try { return await fn(agent) } catch (err) { @@ -46,6 +42,14 @@ export const proxy = async ( } } +export const getPdsEndpoint = (host: string) => { + const service = new URL(`https://${host}`) + if (service.hostname === 'localhost') { + service.protocol = 'http:' + } + return service.origin +} + export const isThisPds = ( ctx: AppContext, pdsDid: string | null | undefined, diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts index d06d867dc1c..9be6ee9db5d 100644 --- a/packages/pds/tests/entryway.test.ts +++ b/packages/pds/tests/entryway.test.ts @@ -39,14 +39,13 @@ describe('entryway', () => { plcRotationKeyK256PrivateKeyHex: plcRotationPriv, }) pds = await TestPds.create({ + // @NOTE plc rotation key and recovery key intentionally not matching entryway isEntryway: false, dbPostgresUrl: process.env.DB_POSTGRES_URL, dbPostgresSchema: 'multi_pds_account_pds', didPlcUrl: plc.url, - recoveryDidKey: recoveryKey, jwtVerifyKeyK256PublicKeyHex: jwtVerifyPub, jwtSigningKeyK256PrivateKeyHex: undefined, // no private key material on pds for jwts - plcRotationKeyK256PrivateKeyHex: plcRotationPriv, }) const pdsRow = await entryway.ctx.db.db .insertInto('pds') @@ -72,7 +71,7 @@ describe('entryway', () => { it('assigns user to a pds.', async () => { await entryway.ctx.db.db.updateTable('pds').set({ weight: 1 }).execute() const { - data: { did }, + data: { did, ...initialSession }, } = await entrywayAgent.api.com.atproto.server.createAccount({ email: 'alice@test.com', handle: 'alice.test', @@ -81,25 +80,8 @@ describe('entryway', () => { alice = did await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() - // @TODO move these steps into account creation process - await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) - await plc - .getClient() - .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) - await plc - .getClient() - .updateAtprotoKey( - did, - pds.ctx.plcRotationKey, - pds.ctx.repoSigningKey.did(), - ) - - await pdsAgent.api.com.atproto.server.createAccount({ - did, - email: 'alice@test.com', - handle: 'alice.test', - password: 'test123', - }) + const token = jose.decodeJwt(initialSession.accessJwt) + expect(token.aud).toBe(pds.ctx.cfg.service.did) const entrywayAccount = await entryway.ctx.services .account(entryway.ctx.db) @@ -118,6 +100,19 @@ describe('entryway', () => { expect(pdsAccount.pdsId).toBe(null) expect(pdsAccount.pdsDid).toBe(null) expect(pdsAccount.root).not.toBe(null) + + const plcClient = plc.getClient() + const doc = await plcClient.getDocumentData(alice) + expect(doc.did).toBe(alice) + expect(doc.alsoKnownAs).toEqual(['at://alice.test']) + expect(doc.services['atproto_pds'].endpoint).toBe( + pds.ctx.cfg.service.publicUrl, + ) + expect(doc.verificationMethods.atproto).toBe(pds.ctx.repoSigningKey.did()) + expect(doc.rotationKeys).toEqual([ + entryway.ctx.cfg.identity.recoveryDidKey, + entryway.ctx.plcRotationKey.did(), + ]) }) it('creates a session that auths across services.', async () => { @@ -127,7 +122,9 @@ describe('entryway', () => { password: 'test123', }) accessToken = session.accessJwt + const tokenBody = jose.decodeJwt(accessToken) const tokenHeader = jose.decodeProtectedHeader(accessToken) + expect(tokenBody.aud).toBe(pds.ctx.cfg.service.did) expect(tokenHeader.alg).toBe('ES256K') // asymmetric, from the jwt key and not the secret const { data: entrywayResult } = await entrywayAgent.api.com.atproto.server.getSession( @@ -150,7 +147,7 @@ describe('entryway', () => { repo: alice, collection: ids.AppBskyActorProfile, rkey: 'self', - record: { displayName: 'Alice' }, + record: { displayName: 'Alice 1' }, }, { headers: SeedClient.getHeaders(accessToken), @@ -169,7 +166,7 @@ describe('entryway', () => { repo: alice, collection: ids.AppBskyActorProfile, rkey: 'self', - record: { displayName: 'Alice!' }, + record: { displayName: 'Alice 2' }, }, { headers: SeedClient.getHeaders(accessToken), @@ -214,7 +211,7 @@ describe('entryway', () => { repo: alice, collection: ids.AppBskyActorProfile, rkey: 'self', - record: { displayName: 'Alice', avatar: blob }, + record: { displayName: 'Alice 3', avatar: blob }, }, { headers: SeedClient.getHeaders(accessToken), @@ -234,7 +231,7 @@ describe('entryway', () => { repo: alice, collection: ids.AppBskyActorProfile, rkey: 'self', - record: { displayName: 'Alice' }, + record: { displayName: 'Alice 4' }, }, { headers: SeedClient.getHeaders(accessToken), @@ -245,9 +242,9 @@ describe('entryway', () => { repo: alice, collection: ids.AppBskyActorProfile, }) - expect(results.records.map((record) => record.uri)).toContain( - profileRef.uri, - ) + expect(results.records.map((record) => [record.uri, record.cid])).toEqual([ + [profileRef.uri, profileRef.cid], + ]) }) it('initiates token refresh when account moves off of entryway.', async () => { @@ -273,16 +270,17 @@ describe('entryway', () => { ) // now move bob to a separate pds await entryway.ctx.services.repo(entryway.ctx.db).deleteRepo(did) - await plc - .getClient() - .updatePds(did, pds.ctx.plcRotationKey, pds.ctx.cfg.service.publicUrl) - await plc - .getClient() - .updateAtprotoKey( - did, - pds.ctx.plcRotationKey, - pds.ctx.repoSigningKey.did(), - ) + const plcClient = plc.getClient() + await plcClient.updatePds( + did, + entryway.ctx.plcRotationKey, + pds.ctx.cfg.service.publicUrl, + ) + await plcClient.updateAtprotoKey( + did, + entryway.ctx.plcRotationKey, + pds.ctx.repoSigningKey.did(), + ) await entryway.ctx.db.db .updateTable('user_account') .set({ pdsId }) From 04c1d6bad6a0665466d467c0b7983d9cec686385 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Sun, 29 Oct 2023 17:04:32 -0400 Subject: [PATCH 025/135] version entryway prerelease --- packages/pds/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/package.json b/packages/pds/package.json index bdfa061b8c9..e3fef718ecd 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.3.0", + "version": "0.3.0-entryway.0", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ From fa6933d1bc60f35b99362e07f229fd9d2c7aef4c Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 30 Oct 2023 00:38:18 -0400 Subject: [PATCH 026/135] pre version entryway --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a3f4b42993..0bdc4300579 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "atp", - "version": "0.0.1", + "version": "0.1.0-entryway.0", "repository": "git@github.com:bluesky-social/atproto.git", "author": "Bluesky PBC ", "license": "MIT", From e5d84299e1229265637926e2288466a121b0bc43 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 30 Oct 2023 11:34:03 -0400 Subject: [PATCH 027/135] Revert "pre version entryway" This reverts commit fa6933d1bc60f35b99362e07f229fd9d2c7aef4c. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0bdc4300579..9a3f4b42993 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "atp", - "version": "0.1.0-entryway.0", + "version": "0.0.1", "repository": "git@github.com:bluesky-social/atproto.git", "author": "Bluesky PBC ", "license": "MIT", From 0d1c83dc629991f98f42db594266d271bfb292cb Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 30 Oct 2023 11:38:07 -0400 Subject: [PATCH 028/135] pre version entryway --- packages/pds/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/package.json b/packages/pds/package.json index bdfa061b8c9..e3fef718ecd 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.3.0", + "version": "0.3.0-entryway.0", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ From f09a8d4c8f8193deb3eff86d315a762e609bbcbf Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 31 Oct 2023 14:39:02 -0400 Subject: [PATCH 029/135] Entryway update handle flow, pds and agent caching (#1785) * fix getAccountByEmail * setup pds agent caching for entryway usage * add caching to getPds for proxy logic * hit pds from entryway on handle change * hit pds from entryway on admin handle change * plumb pds cache for moderation svc * pre version entryway.1 * only provide did doc w/ credentials when validates one of entryway's pdses * fix --- packages/pds/package.json | 2 +- .../com/atproto/admin/updateAccountHandle.ts | 31 +++++++++- .../api/com/atproto/identity/updateHandle.ts | 29 ++++++++- .../api/com/atproto/server/createAccount.ts | 12 ++-- .../pds/src/api/com/atproto/server/util.ts | 13 +++- packages/pds/src/api/proxy.ts | 14 +---- packages/pds/src/auth-verifier.ts | 11 ++++ packages/pds/src/context.ts | 7 +++ packages/pds/src/pds-agents.ts | 22 +++++++ packages/pds/src/services/account/index.ts | 62 ++++++++++++++++--- packages/pds/src/services/index.ts | 7 ++- packages/pds/src/services/moderation/index.ts | 15 +++-- packages/pds/src/services/moderation/views.ts | 6 +- 13 files changed, 185 insertions(+), 46 deletions(-) create mode 100644 packages/pds/src/pds-agents.ts diff --git a/packages/pds/package.json b/packages/pds/package.json index e3fef718ecd..04fceffb5a3 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.3.0-entryway.0", + "version": "0.3.0-entryway.1", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ diff --git a/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts b/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts index 368c2dae586..10441d08ee7 100644 --- a/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts +++ b/packages/pds/src/api/com/atproto/admin/updateAccountHandle.ts @@ -1,4 +1,8 @@ -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' +import { + AuthRequiredError, + InvalidRequestError, + UpstreamFailureError, +} from '@atproto/xrpc-server' import { normalizeAndValidateHandle } from '../../../../handle' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' @@ -7,6 +11,7 @@ import { UserAlreadyExistsError, } from '../../../../services/account' import { httpLogger } from '../../../../logger' +import { isThisPds } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateAccountHandle({ @@ -47,6 +52,30 @@ export default function (server: Server, ctx: AppContext) { }) } + const { pdsDid } = existingAccnt + if (ctx.cfg.service.isEntryway && !isThisPds(ctx, pdsDid)) { + const pds = + pdsDid && + (await ctx.services.account(ctx.db).getPds(pdsDid, { cached: true })) + if (!pds) { + throw new UpstreamFailureError('unknown pds') + } + // the pds emits the handle event on the firehose, but the entryway is responsible for updating the did doc. + // the long flow is: pds(identity.updateHandle) -> entryway(identity.updateHandle) -> pds(admin.updateAccountHandle) + const agent = ctx.pdsAgents.get(pds.host) + await agent.com.atproto.admin.updateAccountHandle( + { + did, + handle: input.body.handle, + }, + { + encoding: 'application/json', + headers: ctx.authVerifier.createAdminRoleHeaders(), + }, + ) + return // do not sequence handle event on the entryway + } + try { await ctx.db.transaction(async (dbTxn) => { await ctx.services.account(dbTxn).sequenceHandle(seqHandleTok) diff --git a/packages/pds/src/api/com/atproto/identity/updateHandle.ts b/packages/pds/src/api/com/atproto/identity/updateHandle.ts index 44a2aaded72..61c7b79dd14 100644 --- a/packages/pds/src/api/com/atproto/identity/updateHandle.ts +++ b/packages/pds/src/api/com/atproto/identity/updateHandle.ts @@ -1,4 +1,5 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, UpstreamFailureError } from '@atproto/xrpc-server' +import { DAY, MINUTE } from '@atproto/common' import { normalizeAndValidateHandle } from '../../../../handle' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' @@ -7,7 +8,7 @@ import { UserAlreadyExistsError, } from '../../../../services/account' import { httpLogger } from '../../../../logger' -import { DAY, MINUTE } from '@atproto/common' +import { isThisPds } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.identity.updateHandle({ @@ -26,6 +27,7 @@ export default function (server: Server, ctx: AppContext) { ], handler: async ({ auth, input }) => { const requester = auth.credentials.did + const pdsDid = auth.credentials.pdsDid const handle = await normalizeAndValidateHandle({ ctx, handle: input.body.handle, @@ -63,6 +65,29 @@ export default function (server: Server, ctx: AppContext) { }) } + if (ctx.cfg.service.isEntryway && !isThisPds(ctx, pdsDid)) { + const pds = + pdsDid && + (await ctx.services.account(ctx.db).getPds(pdsDid, { cached: true })) + if (!pds) { + throw new UpstreamFailureError('unknown pds') + } + // the pds emits the handle event on the firehose, but the entryway is responsible for updating the did doc. + // the long flow is: pds(identity.updateHandle) -> entryway(identity.updateHandle) -> pds(admin.updateAccountHandle) + const agent = ctx.pdsAgents.get(pds.host) + await agent.com.atproto.admin.updateAccountHandle( + { + did: requester, + handle: input.body.handle, + }, + { + encoding: 'application/json', + headers: ctx.authVerifier.createAdminRoleHeaders(), + }, + ) + return // do not sequence handle event on the entryway + } + try { await ctx.db.transaction(async (dbTxn) => { await ctx.services.account(dbTxn).sequenceHandle(seqHandleTok) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 407fc683ec2..b11020ad876 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,7 +1,6 @@ import { MINUTE, cborDecode, cborEncode, check } from '@atproto/common' import { AtprotoData, ensureAtpDocument } from '@atproto/identity' import { InvalidRequestError } from '@atproto/xrpc-server' -import AtpAgent from '@atproto/api' import * as plc from '@did-plc/lib' import disposable from 'disposable-email' import { normalizeAndValidateHandle } from '../../../../handle' @@ -12,8 +11,9 @@ import { countAll } from '../../../../db/util' import { UserAlreadyExistsError } from '../../../../services/account' import AppContext from '../../../../context' import Database from '../../../../db' -import { getPdsEndpoint, isThisPds } from '../../../proxy' +import { isThisPds } from '../../../proxy' import { didDocForSession } from './util' +import { getPdsEndpoint } from '../../../../pds-agents' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -132,7 +132,7 @@ export default function (server: Server, ctx: AppContext) { // Setup repo root await repoTxn.createRepo(did, [], now) } else { - const agent = new AtpAgent({ service: getPdsEndpoint(pds.host) }) + const agent = ctx.pdsAgents.get(pds.host) await agent.com.atproto.server.createAccount({ ...input.body, did, @@ -215,7 +215,7 @@ const getDidAndPlcOp = async ( }> => { const pdsEndpoint = pds ? getPdsEndpoint(pds.host) : ctx.cfg.service.publicUrl const pdsSigningKey = pds - ? await reserveSigningKey(pds.host) + ? await reserveSigningKey(ctx, pds.host) : ctx.repoSigningKey.did() // if the user brings their own PLC op then we validate it then submit it to PLC on their behalf @@ -334,8 +334,8 @@ const assignPds = async (ctx: AppContext) => { return pdses.at(idx) } -const reserveSigningKey = async (host: string) => { - const agent = new AtpAgent({ service: getPdsEndpoint(host) }) +const reserveSigningKey = async (ctx: AppContext, host: string) => { + const agent = ctx.pdsAgents.get(host) const result = await agent.com.atproto.server.reserveSigningKey() return result.data.signingKey } diff --git a/packages/pds/src/api/com/atproto/server/util.ts b/packages/pds/src/api/com/atproto/server/util.ts index fc3bfae8e05..b110f30f3ff 100644 --- a/packages/pds/src/api/com/atproto/server/util.ts +++ b/packages/pds/src/api/com/atproto/server/util.ts @@ -1,3 +1,4 @@ +import { getPdsEndpoint } from '@atproto/common' import * as crypto from '@atproto/crypto' import { DidDocument } from '@atproto/identity' import { ServerConfig } from '../../../../config' @@ -26,7 +27,6 @@ export const getRandomToken = () => { return token.slice(0, 5) + '-' + token.slice(5, 10) } -// @TODO once supporting multiple pdses, validate pds in did doc based on allow-list. export const didDocForSession = async ( ctx: AppContext, did: string, @@ -34,8 +34,15 @@ export const didDocForSession = async ( ): Promise => { if (!ctx.cfg.identity.enableDidDocWithSession) return try { - const didDoc = await ctx.idResolver.did.resolve(did, forceRefresh) - return didDoc ?? undefined + const [didDoc, pdses] = await Promise.all([ + ctx.idResolver.did.resolve(did, forceRefresh), + ctx.services.account(ctx.db).getPdses({ cached: true }), + ]) + if (!didDoc) return + const pdsEndpoint = getPdsEndpoint(didDoc) + const pdsHost = pdsEndpoint && new URL(pdsEndpoint).host + if (!pdses.some((pds) => pds.host === pdsHost)) return + return didDoc } catch (err) { dbLogger.warn({ err, did }, 'failed to resolve did doc') } diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 726b2118a04..803f7478ddf 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -17,14 +17,12 @@ export const proxy = async ( return null // skip proxying } const accountService = ctx.services.account(ctx.db) - const pds = pdsDid && (await accountService.getPds(pdsDid)) + const pds = pdsDid && (await accountService.getPds(pdsDid, { cached: true })) if (!pds) { throw new UpstreamFailureError('unknown pds') } - // @TODO reuse agents - const agent = new AtpAgent({ service: getPdsEndpoint(pds.host) }) try { - return await fn(agent) + return await fn(ctx.pdsAgents.get(pds.host)) } catch (err) { // @TODO may need to pass through special lexicon errors if ( @@ -42,14 +40,6 @@ export const proxy = async ( } } -export const getPdsEndpoint = (host: string) => { - const service = new URL(`https://${host}`) - if (service.hostname === 'localhost') { - service.protocol = 'http:' - } - return service.origin -} - export const isThisPds = ( ctx: AppContext, pdsDid: string | null | undefined, diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 19a326f5155..37ce111951d 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -290,6 +290,17 @@ export class AuthVerifier { return { status: Invalid, admin: false, moderator: false, triage: false } } + createAdminRoleHeaders = () => { + return { + authorization: + 'Basic ' + + ui8.toString( + ui8.fromString(`admin:${this._adminPass}`, 'utf8'), + 'base64pad', + ), + } + } + isUserOrAdmin( auth: AccessOutput | RoleOutput | NullOutput, did: string, diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index ff0f77292c2..f24504a7619 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -20,6 +20,7 @@ import { Crawlers } from './crawlers' import { DiskBlobStore } from './storage' import { getRedisClient } from './redis' import { RuntimeFlags } from './runtime-flags' +import { PdsAgents } from './pds-agents' export type AppContextOptions = { db: Database @@ -38,6 +39,7 @@ export type AppContextOptions = { crawlers: Crawlers appViewAgent: AtpAgent authVerifier: AuthVerifier + pdsAgents: PdsAgents repoSigningKey: crypto.Keypair plcRotationKey: crypto.Keypair cfg: ServerConfig @@ -60,6 +62,7 @@ export class AppContext { public crawlers: Crawlers public appViewAgent: AtpAgent public authVerifier: AuthVerifier + public pdsAgents: PdsAgents public repoSigningKey: crypto.Keypair public plcRotationKey: crypto.Keypair public cfg: ServerConfig @@ -81,6 +84,7 @@ export class AppContext { this.crawlers = opts.crawlers this.appViewAgent = opts.appViewAgent this.authVerifier = opts.authVerifier + this.pdsAgents = opts.pdsAgents this.repoSigningKey = opts.repoSigningKey this.plcRotationKey = opts.plcRotationKey this.cfg = opts.cfg @@ -191,6 +195,8 @@ export class AppContext { crawlers, }) + const pdsAgents = new PdsAgents() + return new AppContext({ db, blobstore, @@ -210,6 +216,7 @@ export class AppContext { authVerifier, repoSigningKey, plcRotationKey, + pdsAgents, cfg, ...(overrides ?? {}), }) diff --git a/packages/pds/src/pds-agents.ts b/packages/pds/src/pds-agents.ts new file mode 100644 index 00000000000..c24a797d201 --- /dev/null +++ b/packages/pds/src/pds-agents.ts @@ -0,0 +1,22 @@ +import AtpAgent from '@atproto/api' + +export class PdsAgents { + // @NOTE only use with entries in the pds table, not for e.g. arbitrary entries found in did documents. + private cache = new Map() + get(host: string) { + const agent = + this.cache.get(host) ?? new AtpAgent({ service: getPdsEndpoint(host) }) + if (!this.cache.has(host)) { + this.cache.set(host, agent) + } + return agent + } +} + +export const getPdsEndpoint = (host: string) => { + const service = new URL(`https://${host}`) + if (service.hostname === 'localhost') { + service.protocol = 'http:' + } + return service.origin +} diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 6b108a7987d..9d80f2fee8a 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -1,4 +1,4 @@ -import { sql } from 'kysely' +import { Selectable, sql } from 'kysely' import { randomStr } from '@atproto/crypto' import { InvalidRequestError } from '@atproto/xrpc-server' import { MINUTE, lessThanAgoMs } from '@atproto/common' @@ -15,12 +15,13 @@ import { AppPassword } from '../../lexicon/types/com/atproto/server/createAppPas import { EmailTokenPurpose } from '../../db/tables/email-token' import { getRandomToken } from '../../api/com/atproto/server/util' import { OptionalJoin } from '../../db/types' +import { Pds } from '../../db/tables/pds' export class AccountService { - constructor(public db: Database) {} + constructor(public db: Database, private pdsCache: PdsCache) {} - static creator() { - return (db: Database) => new AccountService(db) + static creator(pdsCache: PdsCache) { + return (db: Database) => new AccountService(db, pdsCache) } // @TODO decouple account from repo_root, move takedownId. @@ -84,10 +85,10 @@ export class AccountService { qb.where(notSoftDeletedClause(ref('user_account'))), ) .where('email', '=', email.toLowerCase()) - .select(['pds.did as pdsDid']) - .selectAll('user_account') + .selectAll('repo_root') // first so that its possibly-null vals don't shadow other cols .selectAll('did_handle') - .selectAll('repo_root') + .selectAll('user_account') + .select(['pds.did as pdsDid']) .executeTakeFirst() return found || null } @@ -601,13 +602,27 @@ export class AccountService { } } - // @TODO cache w/ in-mem lookup - async getPds(pdsDid: string) { - return await this.db.db + // @NOTE cached due to heavy usage in proxy logic + async getPds(pdsDid: string, opts?: { cached: boolean }) { + if (opts?.cached && this.pdsCache.has(pdsDid)) { + return this.pdsCache.get(pdsDid) + } + const pds = await this.db.db .selectFrom('pds') .where('did', '=', pdsDid) .selectAll() .executeTakeFirst() + if (pds) this.pdsCache.set(pdsDid, pds) + return pds + } + + async getPdses(opts?: { cached: boolean }) { + if (opts?.cached && this.pdsCache.hasAll()) { + return this.pdsCache.getAll() ?? [] + } + const pdses = await this.db.db.selectFrom('pds').selectAll().execute() + this.pdsCache.setAll(pdses) + return pdses } } @@ -648,3 +663,30 @@ export type HandleSequenceToken = { did: string; handle: string } type AccountInfo = UserAccountEntry & DidHandle & OptionalJoin & { pdsDid: string | null } + +export class PdsCache { + private all: PdsResult[] | undefined + private individual = new Map() + get(did: string) { + return this.individual.get(did) + } + has(did: string) { + return this.individual.has(did) + } + set(did: string, pds: PdsResult) { + return this.individual.set(did, pds) + } + getAll() { + return this.all + } + hasAll() { + return this.all !== undefined + } + setAll(pdses: PdsResult[]) { + this.all = pdses + this.individual.clear() + pdses.forEach((pds) => this.individual.set(pds.did, pds)) + } +} + +type PdsResult = Selectable diff --git a/packages/pds/src/services/index.ts b/packages/pds/src/services/index.ts index ffb27d00b5e..c597964383f 100644 --- a/packages/pds/src/services/index.ts +++ b/packages/pds/src/services/index.ts @@ -2,7 +2,7 @@ import { AtpAgent } from '@atproto/api' import * as crypto from '@atproto/crypto' import { BlobStore } from '@atproto/repo' import Database from '../db' -import { AccountService } from './account' +import { AccountService, PdsCache } from './account' import { AuthService } from './auth' import { RecordService } from './record' import { RepoService } from './repo' @@ -36,8 +36,9 @@ export function createServices(resources: { backgroundQueue, crawlers, } = resources + const pdsCache = new PdsCache() return { - account: AccountService.creator(), + account: AccountService.creator(pdsCache), auth: AuthService.creator(identityDid, authKeys), record: RecordService.creator(), repo: RepoService.creator( @@ -53,7 +54,7 @@ export function createServices(resources: { appViewDid, appViewCdnUrlPattern, ), - moderation: ModerationService.creator(blobstore), + moderation: ModerationService.creator(blobstore, pdsCache), } } diff --git a/packages/pds/src/services/moderation/index.ts b/packages/pds/src/services/moderation/index.ts index 3f96d3a1b90..524fe7b7d59 100644 --- a/packages/pds/src/services/moderation/index.ts +++ b/packages/pds/src/services/moderation/index.ts @@ -10,15 +10,20 @@ import { ModerationViews } from './views' import SqlRepoStorage from '../../sql-repo-storage' import { TAKEDOWN } from '../../lexicon/types/com/atproto/admin/defs' import { addHoursToDate } from '../../util/date' +import { PdsCache } from '../account' export class ModerationService { - constructor(public db: Database, public blobstore: BlobStore) {} - - static creator(blobstore: BlobStore) { - return (db: Database) => new ModerationService(db, blobstore) + constructor( + public db: Database, + public blobstore: BlobStore, + public pdsCache: PdsCache, + ) {} + + static creator(blobstore: BlobStore, pdsCache: PdsCache) { + return (db: Database) => new ModerationService(db, blobstore, pdsCache) } - views = new ModerationViews(this.db) + views = new ModerationViews(this.db, this.pdsCache) services = { record: RecordService.creator(), diff --git a/packages/pds/src/services/moderation/views.ts b/packages/pds/src/services/moderation/views.ts index e0285e6f932..b4f64adbfb5 100644 --- a/packages/pds/src/services/moderation/views.ts +++ b/packages/pds/src/services/moderation/views.ts @@ -17,17 +17,17 @@ import { } from '../../lexicon/types/com/atproto/admin/defs' import { OutputSchema as ReportOutput } from '../../lexicon/types/com/atproto/moderation/createReport' import { ModerationAction } from '../../db/tables/moderation' -import { AccountService } from '../account' +import { AccountService, PdsCache } from '../account' import { RecordService } from '../record' import { ModerationReportRowWithHandle } from '.' import { ids } from '../../lexicon/lexicons' import { OptionalJoin } from '../../db/types' export class ModerationViews { - constructor(private db: Database) {} + constructor(private db: Database, private pdsCache: PdsCache) {} services = { - account: AccountService.creator(), + account: AccountService.creator(this.pdsCache), record: RecordService.creator(), } From 2b8633079219c1adf655bd0d5ff5d5be2078ded3 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 31 Oct 2023 23:31:12 -0400 Subject: [PATCH 030/135] build --- .github/workflows/build-and-push-pds-aws.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 097f782d88e..8febb8ce234 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - multi-pds-auth env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From 011c7285be2ea246f8435f4242c27a8585dd6f64 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 1 Nov 2023 12:12:55 -0400 Subject: [PATCH 031/135] fix after merge --- packages/pds/src/api/com/atproto/server/createAccount.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index c04d0edec55..2b8998709a5 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,4 +1,4 @@ -import { MINUTE, cborDecode, cborEncode, check } from '@atproto/common' +import { MINUTE, check } from '@atproto/common' import { AtprotoData, ensureAtpDocument } from '@atproto/identity' import { InvalidRequestError } from '@atproto/xrpc-server' import * as plc from '@did-plc/lib' @@ -136,7 +136,7 @@ export default function (server: Server, ctx: AppContext) { await agent.com.atproto.server.createAccount({ ...input.body, did, - plcOp: plcOp ? cborEncode(plcOp) : undefined, + plcOp: plcOp ?? undefined, }) } @@ -223,7 +223,7 @@ const getDidAndPlcOp = async ( let atpData: AtprotoData let plcOp: plc.Operation try { - plcOp = check.assure(plc.def.operation, cborDecode(input.plcOp)) + plcOp = check.assure(plc.def.operation, input.plcOp) const did = await plc.didForCreateOp(plcOp) const docData = await plc.assureValidCreationOp(did, plcOp) const doc = plc.formatDidDoc(docData) From 5fb44a1426207256833f59ab084c8fc8dc6138db Mon Sep 17 00:00:00 2001 From: devin ivy Date: Wed, 1 Nov 2023 16:22:31 -0400 Subject: [PATCH 032/135] Entryway tweaks to account creation and proxying (#1798) * entryway proxy preferences, tweak to getRecord * add body to reserve signing key in createaccount, do not pass along email/password * skip --- .../src/api/app/bsky/actor/getPreferences.ts | 19 +++++++++++++++++-- .../src/api/app/bsky/actor/putPreferences.ts | 18 ++++++++++++++++-- .../pds/src/api/com/atproto/repo/getRecord.ts | 12 ++++++++++-- .../api/com/atproto/server/createAccount.ts | 17 +++++++++++++---- .../pds/src/api/com/atproto/sync/getBlob.ts | 1 + packages/pds/tests/entryway.test.ts | 3 ++- 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/pds/src/api/app/bsky/actor/getPreferences.ts b/packages/pds/src/api/app/bsky/actor/getPreferences.ts index 92d7d5e47a1..31fc0a78b77 100644 --- a/packages/pds/src/api/app/bsky/actor/getPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/getPreferences.ts @@ -1,12 +1,27 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { AuthScope } from '../../../../auth-verifier' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' -// @TODO may need to proxy to pds export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getPreferences({ auth: ctx.authVerifier.access, - handler: async ({ auth }) => { + handler: async ({ auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.actor.getPreferences( + undefined, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did const { services, db } = ctx let preferences = await services diff --git a/packages/pds/src/api/app/bsky/actor/putPreferences.ts b/packages/pds/src/api/app/bsky/actor/putPreferences.ts index 6bcee2b2e99..e9f9e669c96 100644 --- a/packages/pds/src/api/app/bsky/actor/putPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/putPreferences.ts @@ -2,12 +2,26 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { UserPreference } from '../../../../services/account' import { InvalidRequestError } from '@atproto/xrpc-server' +import { authPassthru, proxy } from '../../../proxy' -// @TODO may need to proxy to pds export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.putPreferences({ auth: ctx.authVerifier.accessCheckTakedown, - handler: async ({ auth, input }) => { + handler: async ({ auth, input, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + await agent.api.app.bsky.actor.putPreferences( + input.body, + authPassthru(req, true), + ) + }, + ) + if (proxied !== null) { + return proxied + } + const { preferences } = input.body const requester = auth.credentials.did const { services, db } = ctx diff --git a/packages/pds/src/api/com/atproto/repo/getRecord.ts b/packages/pds/src/api/com/atproto/repo/getRecord.ts index da2ad881ab5..04c1a34433f 100644 --- a/packages/pds/src/api/com/atproto/repo/getRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/getRecord.ts @@ -2,7 +2,7 @@ import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' -import { isThisPds, resultPassthru } from '../../../proxy' +import { proxy, resultPassthru } from '../../../proxy' import { softDeleted } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { @@ -11,11 +11,19 @@ export default function (server: Server, ctx: AppContext) { const account = await ctx.services.account(ctx.db).getAccount(repo) // fetch from pds if available, if not then fetch from appview - if (!account || !isThisPds(ctx, account.pdsDid)) { + if (!account) { const res = await ctx.appViewAgent.api.com.atproto.repo.getRecord(params) return resultPassthru(res) } + const proxied = await proxy(ctx, account.pdsDid, async (agent) => { + const result = await agent.api.com.atproto.repo.getRecord(params) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + const uri = AtUri.make(account.did, collection, rkey) const record = await ctx.services.record(ctx.db).getRecord(uri, cid || null) if (!record || softDeleted(record)) { diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 876eecc5343..241290e3a98 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,5 +1,6 @@ import { MINUTE, check } from '@atproto/common' import { AtprotoData, ensureAtpDocument } from '@atproto/identity' +import { XRPCError } from '@atproto/xrpc' import { InvalidRequestError } from '@atproto/xrpc-server' import * as plc from '@did-plc/lib' import disposable from 'disposable-email' @@ -141,9 +142,10 @@ export default function (server: Server, ctx: AppContext) { } else { const agent = ctx.pdsAgents.get(pds.host) await agent.com.atproto.server.createAccount({ - ...input.body, did, plcOp: plcOp ?? undefined, + handle: input.body.handle, + recoveryKey: input.body.recoveryKey, }) } @@ -342,9 +344,16 @@ const assignPds = async (ctx: AppContext) => { } const reserveSigningKey = async (ctx: AppContext, host: string) => { - const agent = ctx.pdsAgents.get(host) - const result = await agent.com.atproto.server.reserveSigningKey() - return result.data.signingKey + try { + const agent = ctx.pdsAgents.get(host) + const result = await agent.com.atproto.server.reserveSigningKey({}) + return result.data.signingKey + } catch (err) { + if (err instanceof XRPCError) { + throw new InvalidRequestError('failed to reserve signing key') + } + throw err + } } const randomIndexByWeight = (weights) => { diff --git a/packages/pds/src/api/com/atproto/sync/getBlob.ts b/packages/pds/src/api/com/atproto/sync/getBlob.ts index dd255ca788e..566976eb736 100644 --- a/packages/pds/src/api/com/atproto/sync/getBlob.ts +++ b/packages/pds/src/api/com/atproto/sync/getBlob.ts @@ -5,6 +5,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { notSoftDeletedClause } from '../../../../db/util' import { BlobNotFoundError } from '@atproto/repo' +// @TODO entryway proxy export default function (server: Server, ctx: AppContext) { server.com.atproto.sync.getBlob({ auth: ctx.authVerifier.optionalAccessOrRole, diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts index 9be6ee9db5d..99382406ea0 100644 --- a/packages/pds/tests/entryway.test.ts +++ b/packages/pds/tests/entryway.test.ts @@ -12,7 +12,8 @@ import { } from '@atproto/dev-env' import { ids } from '@atproto/api/src/client/lexicons' -describe('entryway', () => { +// @TODO temporarily skipping while createAccount inputs settle +describe.skip('entryway', () => { let plc: TestPlc let entryway: TestPds let entrywayAgent: AtpAgent From b4aa50ea49f835e60a7f117cab502d8da1de6d60 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 1 Nov 2023 16:24:32 -0400 Subject: [PATCH 033/135] version pds entryway --- packages/pds/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/package.json b/packages/pds/package.json index 04fceffb5a3..57c90869c1e 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.3.0-entryway.1", + "version": "0.3.0-entryway.2", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ From 067eb645bafb87222630db02e9d59107a7c7ced6 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Sat, 4 Nov 2023 14:42:06 -0400 Subject: [PATCH 034/135] Entryway proxying for admin subject status endpoints (#1805) handle entryway proxying for admin subject status endpoints --- .../api/com/atproto/admin/getSubjectStatus.ts | 57 +++++++++++----- .../com/atproto/admin/updateSubjectStatus.ts | 68 +++++++++++++------ 2 files changed, 88 insertions(+), 37 deletions(-) diff --git a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts index 20ded7bc747..468c5d707f5 100644 --- a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts @@ -5,35 +5,42 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSubjectStatus' import { ensureValidAdminAud } from '../../../../auth-verifier' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getSubjectStatus({ auth: ctx.authVerifier.roleOrAdminService, - handler: async ({ params, auth }) => { - const { did, uri, blob } = params + handler: async ({ params, auth, req }) => { const modSrvc = ctx.services.moderation(ctx.db) + const accSrvc = ctx.services.account(ctx.db) + const { did, uri, blob } = parseSubject(params) + ensureValidAdminAud(auth, did) + + const account = await accSrvc.getAccount(did, true) + const proxied = await proxy(ctx, account?.pdsDid, async (agent) => { + const result = await agent.api.com.atproto.admin.getSubjectStatus( + params, + authPassthru(req), + ) + return resultPassthru(result) + }) + if (proxied !== null) { + return proxied + } + let body: OutputSchema | null if (blob) { - if (!did) { - throw new InvalidRequestError( - 'Must provide a did to request blob state', - ) - } - ensureValidAdminAud(auth, did) - body = await modSrvc.getBlobTakedownState(did, CID.parse(blob)) + body = await modSrvc.getBlobTakedownState(did, blob) } else if (uri) { - const parsedUri = new AtUri(uri) - ensureValidAdminAud(auth, parsedUri.hostname) - body = await modSrvc.getRecordTakedownState(parsedUri) - } else if (did) { - ensureValidAdminAud(auth, did) - body = await modSrvc.getRepoTakedownState(did) + body = await modSrvc.getRecordTakedownState(uri) } else { - throw new InvalidRequestError('No provided subject') + body = await modSrvc.getRepoTakedownState(did) } + if (body === null) { throw new InvalidRequestError('Subject not found', 'NotFound') } + return { encoding: 'application/json', body, @@ -41,3 +48,21 @@ export default function (server: Server, ctx: AppContext) { }, }) } + +const parseSubject = (opts: { did?: string; uri?: string; blob?: string }) => { + const { did, uri, blob } = opts + if (blob) { + if (!did) { + throw new InvalidRequestError('Must provide a did to request blob state') + } + const blobCid = CID.parse(blob) + return { did, blob: blobCid } + } else if (uri) { + const parsedUri = new AtUri(uri) + return { did: parsedUri.hostname, uri: parsedUri } + } else if (did) { + return { did } + } else { + throw new InvalidRequestError('No provided subject') + } +} diff --git a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts index 920debba986..93774732db6 100644 --- a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -1,19 +1,21 @@ import { CID } from 'multiformats/cid' import { AtUri } from '@atproto/syntax' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { isRepoRef, isRepoBlobRef, } from '../../../../lexicon/types/com/atproto/admin/defs' +import { InputSchema } from '../../../../lexicon/types/com/atproto/admin/updateSubjectStatus' import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { ensureValidAdminAud } from '../../../../auth-verifier' +import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateSubjectStatus({ auth: ctx.authVerifier.roleOrAdminService, - handler: async ({ input, auth }) => { + handler: async ({ input, auth, req }) => { // if less than moderator access then cannot perform a takedown if (auth.credentials.type === 'role' && !auth.credentials.moderator) { throw new AuthRequiredError( @@ -22,28 +24,38 @@ export default function (server: Server, ctx: AppContext) { } const { subject, takedown } = input.body + const { did, uri, blob } = parseSubject(subject) + ensureValidAdminAud(auth, did) + + const modSrvc = ctx.services.moderation(ctx.db) + const authSrvc = ctx.services.auth(ctx.db) + const accSrvc = ctx.services.account(ctx.db) + + // no need to check if proxying actually occurred or use its result + const account = await accSrvc.getAccount(did, true) + const proxied = await proxy(ctx, account?.pdsDid, async (agent) => { + const result = await agent.api.com.atproto.admin.updateSubjectStatus( + input.body, + authPassthru(req, true), + ) + return resultPassthru(result) + }) + if (takedown) { - const modSrvc = ctx.services.moderation(ctx.db) - const authSrvc = ctx.services.auth(ctx.db) - if (isRepoRef(subject)) { - ensureValidAdminAud(auth, subject.did) + if (blob) { + if (!proxied) { + await modSrvc.updateBlobTakedownState(did, blob, takedown) + } + } else if (uri) { + if (!proxied) { + await modSrvc.updateRecordTakedownState(uri, takedown) + } + } else { + // apply account takedown on entryway in addition to proxied pds await Promise.all([ - modSrvc.updateRepoTakedownState(subject.did, takedown), - authSrvc.revokeRefreshTokensByDid(subject.did), + modSrvc.updateRepoTakedownState(did, takedown), + authSrvc.revokeRefreshTokensByDid(did), ]) - } else if (isStrongRef(subject)) { - const uri = new AtUri(subject.uri) - ensureValidAdminAud(auth, uri.hostname) - await modSrvc.updateRecordTakedownState(uri, takedown) - } else if (isRepoBlobRef(subject)) { - ensureValidAdminAud(auth, subject.did) - await modSrvc.updateBlobTakedownState( - subject.did, - CID.parse(subject.cid), - takedown, - ) - } else { - throw new InvalidRequestError('Invalid subject') } } @@ -57,3 +69,17 @@ export default function (server: Server, ctx: AppContext) { }, }) } + +const parseSubject = (subject: InputSchema['subject']) => { + if (isRepoRef(subject)) { + return { did: subject.did } + } else if (isStrongRef(subject)) { + const uri = new AtUri(subject.uri) + return { did: uri.hostname, uri } + } else if (isRepoBlobRef(subject)) { + const blobCid = CID.parse(subject.cid) + return { did: subject.did, blob: blobCid } + } else { + throw new InvalidRequestError('Invalid subject') + } +} From 586cc06b928d64362858677384ecd1b480a05f2e Mon Sep 17 00:00:00 2001 From: devin ivy Date: Sat, 4 Nov 2023 14:45:09 -0400 Subject: [PATCH 035/135] Polish on entryway blob uploads and gets (#1814) * fix account creation to work between entryway and non-entryway * stream blob uploads * serve redirect to new pds on getBlob * tidy did doc session tests --- packages/api/tests/agent.test.ts | 20 +- .../src/api/com/atproto/repo/uploadBlob.ts | 23 +- .../api/com/atproto/server/createAccount.ts | 357 +++++++++++------- .../pds/src/api/com/atproto/sync/getBlob.ts | 33 +- packages/pds/src/api/proxy.ts | 9 + packages/pds/tests/entryway.test.ts | 107 +++++- 6 files changed, 371 insertions(+), 178 deletions(-) diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index 807046deb00..933326c43f2 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -1,4 +1,3 @@ -import assert from 'assert' import { defaultFetchHandler } from '@atproto/xrpc' import { AtpAgent, @@ -7,7 +6,6 @@ import { AtpSessionData, } from '..' import { TestNetworkNoAppView } from '@atproto/dev-env' -import { getPdsEndpoint, isValidDidDoc } from '@atproto/common-web' describe('agent', () => { let network: TestNetworkNoAppView @@ -15,9 +13,6 @@ describe('agent', () => { beforeAll(async () => { network = await TestNetworkNoAppView.create({ dbPostgresSchema: 'api_agent', - pds: { - enableDidDocWithSession: true, - }, }) }) @@ -48,19 +43,16 @@ describe('agent', () => { expect(agent.session?.did).toEqual(res.data.did) expect(agent.session?.email).toEqual('user1@test.com') expect(agent.session?.emailConfirmed).toEqual(false) - assert(isValidDidDoc(res.data.didDoc)) - expect(agent.api.xrpc.uri.origin).toEqual(getPdsEndpoint(res.data.didDoc)) const { data: sessionInfo } = await agent.api.com.atproto.server.getSession( {}, ) - expect(sessionInfo).toMatchObject({ + expect(sessionInfo).toEqual({ did: res.data.did, handle: res.data.handle, email: 'user1@test.com', emailConfirmed: false, }) - expect(isValidDidDoc(sessionInfo.didDoc)).toBe(true) expect(events.length).toEqual(1) expect(events[0]).toEqual('create') @@ -98,18 +90,15 @@ describe('agent', () => { expect(agent2.session?.did).toEqual(res1.data.did) expect(agent2.session?.email).toEqual('user2@test.com') expect(agent2.session?.emailConfirmed).toEqual(false) - assert(isValidDidDoc(res1.data.didDoc)) - expect(agent2.api.xrpc.uri.origin).toEqual(getPdsEndpoint(res1.data.didDoc)) const { data: sessionInfo } = await agent2.api.com.atproto.server.getSession({}) - expect(sessionInfo).toMatchObject({ + expect(sessionInfo).toEqual({ did: res1.data.did, handle: res1.data.handle, email, emailConfirmed: false, }) - expect(isValidDidDoc(sessionInfo.didDoc)).toBe(true) expect(events.length).toEqual(2) expect(events[0]).toEqual('create') @@ -144,18 +133,15 @@ describe('agent', () => { expect(agent2.hasSession).toEqual(true) expect(agent2.session?.handle).toEqual(res1.data.handle) expect(agent2.session?.did).toEqual(res1.data.did) - assert(isValidDidDoc(res1.data.didDoc)) - expect(agent2.api.xrpc.uri.origin).toEqual(getPdsEndpoint(res1.data.didDoc)) const { data: sessionInfo } = await agent2.api.com.atproto.server.getSession({}) - expect(sessionInfo).toMatchObject({ + expect(sessionInfo).toEqual({ did: res1.data.did, handle: res1.data.handle, email: res1.data.email, emailConfirmed: false, }) - expect(isValidDidDoc(sessionInfo.didDoc)).toBe(true) expect(events.length).toEqual(2) expect(events[0]).toEqual('create') diff --git a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts index 6a56738f17e..cd8172ac9a8 100644 --- a/packages/pds/src/api/com/atproto/repo/uploadBlob.ts +++ b/packages/pds/src/api/com/atproto/repo/uploadBlob.ts @@ -1,11 +1,10 @@ -import { streamToBytes } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { authPassthru, ensureThisPds, + getStreamingRequestInit, proxy, - resultPassthru, } from '../../../proxy' export default function (server: Server, ctx: AppContext) { @@ -16,14 +15,18 @@ export default function (server: Server, ctx: AppContext) { ctx, auth.credentials.audience, async (agent) => { - const result = await agent.api.com.atproto.repo.uploadBlob( - await streamToBytes(input.body), // @TODO proxy streaming - { - ...authPassthru(req), - encoding: input.encoding, - }, - ) - return resultPassthru(result) + const reqInit = getStreamingRequestInit(input.body) + reqInit.method = req.method + reqInit.headers = { + ...authPassthru(req)?.headers, + 'content-type': + req.headers['content-type'] || 'application/octet-stream', + } + const res = await fetch(`${agent.service.origin}${req.path}`, reqInit) + return { + encoding: 'application/json' as const, + body: await res.json(), + } }, ) if (proxied !== null) { diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 241290e3a98..c4dea6cf27f 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -1,4 +1,6 @@ +import assert from 'node:assert' import { MINUTE, check } from '@atproto/common' +import { randomStr } from '@atproto/crypto' import { AtprotoData, ensureAtpDocument } from '@atproto/identity' import { XRPCError } from '@atproto/xrpc' import { InvalidRequestError } from '@atproto/xrpc-server' @@ -12,9 +14,9 @@ import { countAll } from '../../../../db/util' import { UserAlreadyExistsError } from '../../../../services/account' import AppContext from '../../../../context' import Database from '../../../../db' -import { isThisPds } from '../../../proxy' import { didDocForSession } from './util' import { getPdsEndpoint } from '../../../../pds-agents' +import { isThisPds } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -23,50 +25,17 @@ export default function (server: Server, ctx: AppContext) { points: 100, }, handler: async ({ input, req }) => { - const { email, password, inviteCode } = input.body - if (!ctx.cfg.service.isEntryway && !input.body.did && !input.body.plcOp) { - throw new InvalidRequestError( - 'non-entryway pds requires bringing a DID or PLC operation', - ) - } - - if (!email) { - throw new InvalidRequestError('Missing input: "email"') - } else if (!password) { - throw new InvalidRequestError('Missing input: "password"') - } else if (input.body.plcOp) { - throw new InvalidRequestError('Unsupported input: "plcOp"') - } - - if (ctx.cfg.invites.required && !inviteCode) { - throw new InvalidRequestError( - 'No invite code provided', - 'InvalidInviteCode', - ) - } - - if (!disposable.validate(email)) { - throw new InvalidRequestError( - 'This email address is not supported, please use a different email.', - ) - } - - // normalize & ensure valid handle - const handle = await normalizeAndValidateHandle({ - ctx, - handle: input.body.handle, - did: input.body.did, - }) - - // check that the invite code still has uses - if (ctx.cfg.invites.required && inviteCode) { - await ensureCodeIsAvailable(ctx.db, inviteCode) - } - - // determine the did & any plc ops we need to send - // if the provided did document is poorly setup, we throw - const pds = await assignPds(ctx) - const { did, plcOp } = await getDidAndPlcOp(ctx, pds, handle, input.body) + const { + did, + handle, + email, + password, + inviteCode, + plcOp, + pds: entrywayAssignedPds, + } = isInputForPdsViaEntryway(ctx, input.body) + ? await validateInputsForPdsViaEntryway(ctx, input.body) + : await validateInputsForPdsViaUser(ctx, input.body) const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) @@ -88,7 +57,7 @@ export default function (server: Server, ctx: AppContext) { email, handle, did, - pdsId: pds?.id, + pdsId: entrywayAssignedPds?.id, passwordScrypt, }) } catch (err) { @@ -104,7 +73,7 @@ export default function (server: Server, ctx: AppContext) { } // Generate a real did with PLC - if (plcOp && isThisPds(ctx, pds?.did)) { + if (plcOp && !entrywayAssignedPds) { try { await ctx.plcClient.sendOperation(did, plcOp) } catch (err) { @@ -132,21 +101,21 @@ export default function (server: Server, ctx: AppContext) { .auth(dbTxn) .createSession({ did, - pdsDid: pds?.did ?? null, + pdsDid: entrywayAssignedPds?.did ?? null, appPasswordName: null, }) - if (!pds || isThisPds(ctx, pds.did)) { - // Setup repo root - await repoTxn.createRepo(did, [], now) - } else { - const agent = ctx.pdsAgents.get(pds.host) + if (entrywayAssignedPds) { + const agent = ctx.pdsAgents.get(entrywayAssignedPds.host) await agent.com.atproto.server.createAccount({ did, plcOp: plcOp ?? undefined, handle: input.body.handle, recoveryKey: input.body.recoveryKey, }) + } else { + // Setup repo root + await repoTxn.createRepo(did, [], now) } return { @@ -172,6 +141,129 @@ export default function (server: Server, ctx: AppContext) { }) } +const isInputForPdsViaEntryway = ( + ctx: AppContext, + input: CreateAccountInput, +) => { + // detects case where pds is being contacted by an entryway. + // this case is just for testing purposes. + return ( + !ctx.cfg.service.isEntryway && + input.did && + input.plcOp && + !input.email && + !input.password + ) +} + +const validateInputsForPdsViaEntryway = async ( + ctx: AppContext, + input: CreateAccountInput, +) => { + // @NOTE non-entryway codepath, just for testing purposes. + assert(!ctx.cfg.service.isEntryway) + const { did, handle, plcOp } = input + if (!did || !input.plcOp) { + throw new InvalidRequestError( + 'non-entryway pds requires bringing a DID and plcOp', + ) + } + if (!check.is(plcOp, plc.def.operation)) { + throw new InvalidRequestError('invalid plc operation', 'IncompatibleDidDoc') + } + + await plc.assureValidOp(plcOp) + const doc = plc.formatDidDoc({ did, ...plcOp }) + const data = ensureAtpDocument(doc) + + // @NOTE a real pds behaind an entryway would typically check that the doc includes entryway's rotation key + validateAtprotoData(data, { + handle, + pds: ctx.cfg.service.publicUrl, + signingKey: ctx.repoSigningKey.did(), + }) + + return { + did, + handle, + // @NOTE a real pds behaind an entryway would not keep an email or password + email: `${did}@email.invalid`, + password: randomStr(16, 'hex'), + inviteCode: undefined, + plcOp, + pds: undefined, + } +} + +const validateInputsForPdsViaUser = async ( + ctx: AppContext, + input: CreateAccountInput, +) => { + const { email, password, inviteCode } = input + if (input.plcOp) { + throw new InvalidRequestError('Unsupported input: "plcOp"') + } + + if (ctx.cfg.invites.required && !inviteCode) { + throw new InvalidRequestError( + 'No invite code provided', + 'InvalidInviteCode', + ) + } + + if (!email) { + throw new InvalidRequestError('Email is required') + } else if (!disposable.validate(email)) { + throw new InvalidRequestError( + 'This email address is not supported, please use a different email.', + ) + } + + if (!password) { + throw new InvalidRequestError('Password is required') + } + + // normalize & ensure valid handle + const handle = await normalizeAndValidateHandle({ + ctx, + handle: input.handle, + did: input.did, + }) + + // check that the invite code still has uses + if (ctx.cfg.invites.required && inviteCode) { + await ensureCodeIsAvailable(ctx.db, inviteCode) + } + + // determine the did & any plc ops we need to send + // if the provided did document is poorly setup, we throw + const pds = await assignPds(ctx) + const pdsEndpoint = pds ? getPdsEndpoint(pds.host) : ctx.cfg.service.publicUrl + const pdsSigningKey = pds + ? await reserveSigningKey(ctx, pds.host) + : ctx.repoSigningKey.did() + + const { did, plcOp } = input.did + ? await validateExistingDid( + ctx, + handle, + input.did, + pdsEndpoint, + pdsSigningKey, + ) + : await createDidAndPlcOp(ctx, handle, input, pdsEndpoint, pdsSigningKey) + + return { + did, + handle, + email, + password, + inviteCode, + plcOp, + pds, + } +} + export const ensureCodeIsAvailable = async ( db: Database, inviteCode: string, @@ -213,134 +305,117 @@ export const ensureCodeIsAvailable = async ( } } -const getDidAndPlcOp = async ( +const createDidAndPlcOp = async ( ctx: AppContext, - pds: { host: string } | undefined, handle: string, input: CreateAccountInput, + pdsEndpoint: string, + signingDidKey: string, ): Promise<{ did: string plcOp: plc.Operation | null }> => { - const pdsEndpoint = pds ? getPdsEndpoint(pds.host) : ctx.cfg.service.publicUrl - const pdsSigningKey = pds - ? await reserveSigningKey(ctx, pds.host) - : ctx.repoSigningKey.did() - - // if the user brings their own PLC op then we validate it then submit it to PLC on their behalf - if (input.plcOp) { - let atpData: AtprotoData - let plcOp: plc.Operation - try { - plcOp = check.assure(plc.def.operation, input.plcOp) - const did = await plc.didForCreateOp(plcOp) - const docData = await plc.assureValidCreationOp(did, plcOp) - const doc = plc.formatDidDoc(docData) - atpData = ensureAtpDocument(doc) - } catch (err) { - throw new InvalidRequestError( - 'could not validate PLC creation operation', - 'InvalidPlcOp', - ) - } - if (input.did && input.did !== atpData.did) { - throw new InvalidRequestError( - 'the DID does not match the PLC creation operation', - 'IncompatiblePlcOp', - ) - } - if (atpData.handle !== handle) { - throw new InvalidRequestError( - 'provided handle does not match PLC operation handle', - 'IncompatiblePlcOp', - ) - } else if (atpData.pds !== pdsEndpoint) { - throw new InvalidRequestError( - 'PLC operation pds endpoint does not match service endpoint', - 'IncompatiblePlcOp', - ) - } else if (atpData.signingKey !== pdsSigningKey) { - throw new InvalidRequestError( - 'PLC operation signing key does not match service signing key', - 'IncompatiblePlcOp', - ) - } - return { did: atpData.did, plcOp } - } - // if the user is not bringing a DID, then we format a create op for PLC - // but we don't send until we ensure the username & email are available - if (!input.did) { - const rotationKeys = [ctx.plcRotationKey.did()] - if (ctx.cfg.identity.recoveryDidKey) { - rotationKeys.unshift(ctx.cfg.identity.recoveryDidKey) - } - if (input.recoveryKey) { - rotationKeys.unshift(input.recoveryKey) - } - const plcCreate = await plc.createOp({ - signingKey: pdsSigningKey, - rotationKeys, - handle, - pds: pdsEndpoint, - signer: ctx.plcRotationKey, - }) - return { - did: plcCreate.did, - plcOp: plcCreate.op, - } + const rotationKeys = [ctx.plcRotationKey.did()] + if (ctx.cfg.identity.recoveryDidKey) { + rotationKeys.unshift(ctx.cfg.identity.recoveryDidKey) + } + if (input.recoveryKey) { + rotationKeys.unshift(input.recoveryKey) + } + const plcCreate = await plc.createOp({ + signingKey: signingDidKey, + rotationKeys, + handle, + pds: pdsEndpoint, + signer: ctx.plcRotationKey, + }) + return { + did: plcCreate.did, + plcOp: plcCreate.op, } +} +const validateExistingDid = async ( + ctx: AppContext, + handle: string, + did: string, + pdsEndpoint: string, + signingDidKey: string, +): 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(input.did) + atpData = await ctx.idResolver.did.resolveAtprotoData(did) } catch (err) { throw new InvalidRequestError( - `could not resolve valid DID document: ${input.did}`, + `could not resolve valid DID document: ${did}`, 'UnresolvableDid', ) } - if (atpData.handle !== handle) { + validateAtprotoData(atpData, { + handle, + pds: pdsEndpoint, + signingKey: signingDidKey, + }) + + if (did.startsWith('did:plc') && ctx.cfg.service.isEntryway) { + 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, plcOp: null } +} + +const validateAtprotoData = ( + data: AtprotoData, + expected: { + handle: string + pds: string + signingKey: string + }, +) => { + // 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 + if (data.handle !== expected.handle) { throw new InvalidRequestError( 'provided handle does not match DID document handle', 'IncompatibleDidDoc', ) - } else if (atpData.pds !== pdsEndpoint) { + } else if (data.pds !== expected.pds) { throw new InvalidRequestError( 'DID document pds endpoint does not match service endpoint', 'IncompatibleDidDoc', ) - } else if (atpData.signingKey !== pdsSigningKey) { + } else if (data.signingKey !== expected.signingKey) { throw new InvalidRequestError( 'DID document signing key does not match service signing key', 'IncompatibleDidDoc', ) } - - // non-entryway pds doesn't require matching plc rotation key, will be handled by its entryway - if (input.did.startsWith('did:plc') && ctx.cfg.service.isEntryway) { - const data = await ctx.plcClient.getDocumentData(input.did) - if (!data.rotationKeys.includes(ctx.plcRotationKey.did())) { - throw new InvalidRequestError( - 'PLC DID does not include service rotation key', - 'IncompatibleDidDoc', - ) - } - } - - return { did: input.did, plcOp: null } } // @TODO this implementation is a stub const assignPds = async (ctx: AppContext) => { if (!ctx.cfg.service.isEntryway) return - const pdses = await ctx.db.db.selectFrom('pds').selectAll().execute() + const accountService = ctx.services.account(ctx.db) + const pdses = await accountService.getPdses() const idx = randomIndexByWeight(pdses.map((pds) => pds.weight)) if (idx === -1) return - return pdses.at(idx) + const pds = pdses.at(idx) + if (isThisPds(ctx, pds?.did)) return + return pds } const reserveSigningKey = async (ctx: AppContext, host: string) => { diff --git a/packages/pds/src/api/com/atproto/sync/getBlob.ts b/packages/pds/src/api/com/atproto/sync/getBlob.ts index 566976eb736..138b26f297c 100644 --- a/packages/pds/src/api/com/atproto/sync/getBlob.ts +++ b/packages/pds/src/api/com/atproto/sync/getBlob.ts @@ -2,24 +2,43 @@ import { CID } from 'multiformats/cid' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' -import { notSoftDeletedClause } from '../../../../db/util' +import { notSoftDeletedClause, softDeleted } from '../../../../db/util' import { BlobNotFoundError } from '@atproto/repo' +import { isThisPds } from '../../../proxy' +import { getPdsEndpoint } from '../../../../pds-agents' // @TODO entryway proxy export default function (server: Server, ctx: AppContext) { server.com.atproto.sync.getBlob({ auth: ctx.authVerifier.optionalAccessOrRole, - handler: async ({ params, res, auth }) => { + handler: async ({ params, auth, req, res }) => { const { ref } = ctx.db.db.dynamic const { did } = params - if (!ctx.authVerifier.isUserOrAdmin(auth, params.did)) { - const available = await ctx.services - .account(ctx.db) - .isRepoAvailable(did) - if (!available) { + const accountService = ctx.services.account(ctx.db) + const account = await accountService.getAccount(did, true) + + if ( + !account || + (softDeleted(account) && + !ctx.authVerifier.isUserOrAdmin(auth, params.did)) + ) { + throw new InvalidRequestError(`Could not find repo for DID: ${did}`) + } + + if (account.pdsDid && !isThisPds(ctx, account.pdsDid)) { + const pds = await accountService.getPds(account.pdsDid, { + cached: true, + }) + if (!pds) { throw new InvalidRequestError(`Could not find repo for DID: ${did}`) } + res.setHeader('location', getPdsEndpoint(pds.host) + req.url) + return { + status: 302, + error: 'Redirecting', + message: 'Redirecting to new blob location', + } } const found = await ctx.db.db diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 803f7478ddf..a93e0ff4bdc 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -1,3 +1,4 @@ +import { Readable } from 'node:stream' import * as express from 'express' import AtpAgent from '@atproto/api' import { Headers, XRPCError } from '@atproto/xrpc' @@ -40,6 +41,14 @@ export const proxy = async ( } } +export const getStreamingRequestInit = (body: Readable): RequestInit => { + const reqInit: RequestInit & { duplex: string } = { + body: Readable.toWeb(body), + duplex: 'half', + } + return reqInit +} + export const isThisPds = ( ctx: AppContext, pdsDid: string | null | undefined, diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts index 99382406ea0..f4f56a7331e 100644 --- a/packages/pds/tests/entryway.test.ts +++ b/packages/pds/tests/entryway.test.ts @@ -11,9 +11,9 @@ import { mockNetworkUtilities, } from '@atproto/dev-env' import { ids } from '@atproto/api/src/client/lexicons' +import { getPdsEndpoint, isValidDidDoc } from '@atproto/common' -// @TODO temporarily skipping while createAccount inputs settle -describe.skip('entryway', () => { +describe('entryway', () => { let plc: TestPlc let entryway: TestPds let entrywayAgent: AtpAgent @@ -21,6 +21,7 @@ describe.skip('entryway', () => { let pdsAgent: AtpAgent let alice: string let accessToken: string + let refreshToken: string let pdsId: number beforeAll(async () => { @@ -38,6 +39,7 @@ describe.skip('entryway', () => { recoveryDidKey: recoveryKey, jwtSigningKeyK256PrivateKeyHex: jwtSigningPriv, plcRotationKeyK256PrivateKeyHex: plcRotationPriv, + enableDidDocWithSession: true, }) pds = await TestPds.create({ // @NOTE plc rotation key and recovery key intentionally not matching entryway @@ -81,6 +83,10 @@ describe.skip('entryway', () => { alice = did await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() + assert(isValidDidDoc(initialSession.didDoc)) + expect(initialSession.didDoc.id).toBe(alice) + expect(getPdsEndpoint(initialSession.didDoc)).toBe(pds.url) + const token = jose.decodeJwt(initialSession.accessJwt) expect(token.aud).toBe(pds.ctx.cfg.service.did) @@ -122,11 +128,66 @@ describe.skip('entryway', () => { identifier: alice, password: 'test123', }) + accessToken = session.accessJwt + refreshToken = session.refreshJwt + assert(isValidDidDoc(session.didDoc)) + expect(session.didDoc.id).toBe(alice) + expect(getPdsEndpoint(session.didDoc)).toBe(pds.url) + const tokenBody = jose.decodeJwt(accessToken) const tokenHeader = jose.decodeProtectedHeader(accessToken) expect(tokenBody.aud).toBe(pds.ctx.cfg.service.did) expect(tokenHeader.alg).toBe('ES256K') // asymmetric, from the jwt key and not the secret + + const { data: entrywayResult } = + await entrywayAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + const { data: pdsResult } = + await pdsAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + assert(isValidDidDoc(entrywayResult.didDoc)) + expect(entrywayResult.didDoc.id).toBe(alice) + expect(getPdsEndpoint(entrywayResult.didDoc)).toBe(pds.url) + expect(entrywayResult.did).toBe(alice) + expect(pdsResult.did).toBe(alice) + }) + + it('refreshes a session on entryway that auths across services.', async () => { + const { data: entrywaySession } = + await entrywayAgent.api.com.atproto.server.refreshSession(undefined, { + headers: SeedClient.getHeaders(refreshToken), + }) + accessToken = entrywaySession.accessJwt + refreshToken = entrywaySession.refreshJwt + assert(isValidDidDoc(entrywaySession.didDoc)) + expect(entrywaySession.didDoc.id).toBe(alice) + expect(getPdsEndpoint(entrywaySession.didDoc)).toBe(pds.url) + const { data: entrywayResult } = + await entrywayAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + const { data: pdsResult } = + await pdsAgent.api.com.atproto.server.getSession( + {}, + { headers: SeedClient.getHeaders(accessToken) }, + ) + expect(entrywayResult.did).toBe(alice) + expect(pdsResult.did).toBe(alice) + }) + + it('refreshes a session on pds that auths across services.', async () => { + const { data: pdsSession } = + await entrywayAgent.api.com.atproto.server.refreshSession(undefined, { + headers: SeedClient.getHeaders(refreshToken), + }) + accessToken = pdsSession.accessJwt + refreshToken = pdsSession.refreshJwt const { data: entrywayResult } = await entrywayAgent.api.com.atproto.server.getSession( {}, @@ -226,13 +287,53 @@ describe.skip('entryway', () => { expect(Buffer.compare(file, bytes)).toBe(0) }) + it('redirects blob uploads to pds.', async () => { + const file = await fs.readFile('tests/sample-img/key-portrait-small.jpg') + const { + data: { blob }, + } = await entrywayAgent.api.com.atproto.repo.uploadBlob(file, { + encoding: 'image/jpeg', + headers: SeedClient.getHeaders(accessToken), + }) + await entrywayAgent.com.atproto.repo.putRecord( + { + repo: alice, + collection: ids.AppBskyActorProfile, + rkey: 'self', + record: { displayName: 'Alice 4', avatar: blob }, + }, + { + headers: SeedClient.getHeaders(accessToken), + encoding: 'application/json', + }, + ) + // check with fetch to confirm redirect + const url = new URL( + '/xrpc/com.atproto.sync.getBlob', + entrywayAgent.service.origin, + ) + url.searchParams.set('did', alice) + url.searchParams.set('cid', blob.ref.toString()) + const fetchResult = await fetch(url) + expect(fetchResult.redirected).toBe(true) + const fetchBlob = await fetchResult.blob() + const fetchBytes = Buffer.from(await fetchBlob.arrayBuffer()) + expect(Buffer.compare(file, fetchBytes)).toBe(0) + // check with atp agent to ensure our client handles the redirect + const { data: bytes } = await entrywayAgent.com.atproto.sync.getBlob({ + did: alice, + cid: blob.ref.toString(), + }) + expect(Buffer.compare(file, bytes)).toBe(0) + }) + it('proxies repo reads to pds.', async () => { const { data: profileRef } = await entrywayAgent.com.atproto.repo.putRecord( { repo: alice, collection: ids.AppBskyActorProfile, rkey: 'self', - record: { displayName: 'Alice 4' }, + record: { displayName: 'Alice 5' }, }, { headers: SeedClient.getHeaders(accessToken), From 64d2c01b05c20b9f3e188b68e4624f2c01c057fb Mon Sep 17 00:00:00 2001 From: devin ivy Date: Mon, 6 Nov 2023 19:40:58 -0500 Subject: [PATCH 036/135] Ensure did doc in session has matching account pds (#1831) * ensure did doc in session has matching account pds * catch signing token auth error on appview requests during pds migration --- .../pds/src/api/app/bsky/actor/getProfile.ts | 17 ++++++--- .../pds/src/api/app/bsky/actor/getProfiles.ts | 15 +++++--- .../src/api/app/bsky/actor/getSuggestions.ts | 15 +++++--- .../src/api/app/bsky/actor/searchActors.ts | 15 +++++--- .../app/bsky/actor/searchActorsTypeahead.ts | 14 +++++--- .../src/api/app/bsky/feed/getActorFeeds.ts | 15 +++++--- .../src/api/app/bsky/feed/getActorLikes.ts | 17 ++++++--- .../src/api/app/bsky/feed/getAuthorFeed.ts | 17 ++++++--- packages/pds/src/api/app/bsky/feed/getFeed.ts | 22 ++++++++---- .../src/api/app/bsky/feed/getFeedGenerator.ts | 15 +++++--- .../api/app/bsky/feed/getFeedGenerators.ts | 15 +++++--- .../pds/src/api/app/bsky/feed/getLikes.ts | 15 +++++--- .../pds/src/api/app/bsky/feed/getListFeed.ts | 15 +++++--- .../src/api/app/bsky/feed/getPostThread.ts | 28 +++++++++------ .../pds/src/api/app/bsky/feed/getPosts.ts | 15 +++++--- .../src/api/app/bsky/feed/getRepostedBy.ts | 15 +++++--- .../api/app/bsky/feed/getSuggestedFeeds.ts | 15 +++++--- .../pds/src/api/app/bsky/feed/getTimeline.ts | 15 +++++--- .../pds/src/api/app/bsky/graph/getBlocks.ts | 15 +++++--- .../src/api/app/bsky/graph/getFollowers.ts | 17 ++++++--- .../pds/src/api/app/bsky/graph/getFollows.ts | 17 ++++++--- .../pds/src/api/app/bsky/graph/getList.ts | 15 +++++--- .../src/api/app/bsky/graph/getListBlocks.ts | 15 +++++--- .../src/api/app/bsky/graph/getListMutes.ts | 15 +++++--- .../pds/src/api/app/bsky/graph/getLists.ts | 15 +++++--- .../pds/src/api/app/bsky/graph/getMutes.ts | 15 +++++--- .../bsky/graph/getSuggestedFollowsByActor.ts | 14 +++++--- .../pds/src/api/app/bsky/graph/muteActor.ts | 12 ++++--- .../src/api/app/bsky/graph/muteActorList.ts | 12 ++++--- .../pds/src/api/app/bsky/graph/unmuteActor.ts | 12 ++++--- .../src/api/app/bsky/graph/unmuteActorList.ts | 12 ++++--- .../app/bsky/notification/getUnreadCount.ts | 14 +++++--- .../bsky/notification/listNotifications.ts | 14 +++++--- .../api/app/bsky/notification/registerPush.ts | 9 +++-- .../api/app/bsky/notification/updateSeen.ts | 12 ++++--- .../src/api/app/bsky/unspecced/getPopular.ts | 15 +++++--- .../unspecced/getPopularFeedGenerators.ts | 14 +++++--- .../com/atproto/moderation/createReport.ts | 14 +++++--- .../api/com/atproto/server/createAccount.ts | 3 +- .../api/com/atproto/server/createSession.ts | 2 +- .../src/api/com/atproto/server/getSession.ts | 17 +++++---- .../api/com/atproto/server/refreshSession.ts | 2 +- .../pds/src/api/com/atproto/server/util.ts | 36 ++++++++++++------- packages/pds/src/api/proxy.ts | 23 +++++++++++- 44 files changed, 466 insertions(+), 190 deletions(-) diff --git a/packages/pds/src/api/app/bsky/actor/getProfile.ts b/packages/pds/src/api/app/bsky/actor/getProfile.ts index 822361ea7f0..5b584f907af 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfile.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfile.ts @@ -3,7 +3,12 @@ import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/actor/getProfile' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getProfile({ @@ -28,9 +33,13 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.type === 'access' ? auth.credentials.did : null - const res = await ctx.appViewAgent.api.app.bsky.actor.getProfile( - params, - requester ? await ctx.serviceAuthHeaders(requester) : authPassthru(req), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.actor.getProfile( + params, + requester + ? await ctx.serviceAuthHeaders(requester) + : authPassthru(req), + ), ) if (res.data.did === requester) { return await handleReadAfterWrite(ctx, requester, res, getProfileMunge) diff --git a/packages/pds/src/api/app/bsky/actor/getProfiles.ts b/packages/pds/src/api/app/bsky/actor/getProfiles.ts index 539dfa2a0b8..cc32abd7611 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfiles.ts @@ -2,7 +2,12 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { OutputSchema } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import { LocalRecords } from '../../../../services/local' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' import { handleReadAfterWrite } from '../util/read-after-write' export default function (server: Server, ctx: AppContext) { @@ -25,9 +30,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.actor.getProfiles( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.actor.getProfiles( + params, + await ctx.serviceAuthHeaders(requester), + ), ) const hasSelf = res.data.profiles.some((prof) => prof.did === requester) if (hasSelf) { diff --git a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts index 4a50fafa86e..f3015f375a7 100644 --- a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.getSuggestions({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.actor.getSuggestions( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.actor.getSuggestions( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/actor/searchActors.ts b/packages/pds/src/api/app/bsky/actor/searchActors.ts index 9342ec86319..3546149802f 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActors.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActors.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActors({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.actor.searchActors( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.actor.searchActors( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts index cc12cd8b7b0..7dce6b4ba50 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.searchActorsTypeahead({ @@ -22,11 +27,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = - await ctx.appViewAgent.api.app.bsky.actor.searchActorsTypeahead( + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.actor.searchActorsTypeahead( params, await ctx.serviceAuthHeaders(requester), - ) + ), + ) return { encoding: 'application/json', body: res.data, diff --git a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts index 6a6666f85e7..5e7f38ce533 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getActorFeeds({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getActorFeeds( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getActorFeeds( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts index 8e6a581c838..95c9ad64d46 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts @@ -3,7 +3,12 @@ import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getActorLikes({ @@ -29,9 +34,13 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.type === 'access' ? auth.credentials.did : null - const res = await ctx.appViewAgent.api.app.bsky.feed.getActorLikes( - params, - requester ? await ctx.serviceAuthHeaders(requester) : authPassthru(req), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getActorLikes( + params, + requester + ? await ctx.serviceAuthHeaders(requester) + : authPassthru(req), + ), ) if (requester) { return await handleReadAfterWrite(ctx, requester, res, getAuthorMunge) diff --git a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts index 63a88910b9f..a509079b4cb 100644 --- a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts @@ -4,7 +4,12 @@ import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getAuthorF import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' import { isReasonRepost } from '../../../../lexicon/types/app/bsky/feed/defs' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getAuthorFeed({ @@ -29,9 +34,13 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.type === 'access' ? auth.credentials.did : null - const res = await ctx.appViewAgent.api.app.bsky.feed.getAuthorFeed( - params, - requester ? await ctx.serviceAuthHeaders(requester) : authPassthru(req), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getAuthorFeed( + params, + requester + ? await ctx.serviceAuthHeaders(requester) + : authPassthru(req), + ), ) if (requester) { return await handleReadAfterWrite(ctx, requester, res, getAuthorMunge) diff --git a/packages/pds/src/api/app/bsky/feed/getFeed.ts b/packages/pds/src/api/app/bsky/feed/getFeed.ts index 5a7f143057a..6e1a943621b 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeed.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeed({ @@ -23,14 +28,17 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did - const { data: feed } = - await ctx.appViewAgent.api.app.bsky.feed.getFeedGenerator( + const { data: feed } = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getFeedGenerator( { feed: params.feed }, await ctx.serviceAuthHeaders(requester), - ) - const res = await ctx.appViewAgent.api.app.bsky.feed.getFeed( - params, - await ctx.serviceAuthHeaders(requester, feed.view.did), + ), + ) + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getFeed( + params, + await ctx.serviceAuthHeaders(requester, feed.view.did), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts index dd6d08e752a..5569d55db95 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerator({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getFeedGenerator( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getFeedGenerator( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts index 2de5f82bb7b..c0913470a4e 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedGenerators({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getFeedGenerators( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getFeedGenerators( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getLikes.ts b/packages/pds/src/api/app/bsky/feed/getLikes.ts index f3143ca2d54..173c469bf8e 100644 --- a/packages/pds/src/api/app/bsky/feed/getLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getLikes.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getLikes({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getLikes( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getLikes( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getListFeed.ts b/packages/pds/src/api/app/bsky/feed/getListFeed.ts index 4fed16cd460..086cfff20dc 100644 --- a/packages/pds/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getListFeed.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getListFeed({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getListFeed( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getListFeed( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getPostThread.ts b/packages/pds/src/api/app/bsky/feed/getPostThread.ts index 27fe72b8039..354e4b168aa 100644 --- a/packages/pds/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/pds/src/api/app/bsky/feed/getPostThread.ts @@ -22,7 +22,12 @@ import { getRepoRev, handleReadAfterWrite, } from '../util/read-after-write' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getPostThread({ @@ -49,9 +54,8 @@ export default function (server: Server, ctx: AppContext) { auth.credentials.type === 'access' ? auth.credentials.did : null if (!requester) { - const res = await ctx.appViewAgent.api.app.bsky.feed.getPostThread( - params, - authPassthru(req), + const res = await proxyAppView(ctx, (agent) => + agent.api.app.bsky.feed.getPostThread(params, authPassthru(req)), ) return { @@ -61,9 +65,11 @@ export default function (server: Server, ctx: AppContext) { } try { - const res = await ctx.appViewAgent.api.app.bsky.feed.getPostThread( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getPostThread( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return await handleReadAfterWrite( @@ -218,9 +224,11 @@ const readAfterWriteNotFound = async ( const highestParent = getHighestParent(thread) if (highestParent) { try { - const parentsRes = await ctx.appViewAgent.api.app.bsky.feed.getPostThread( - { uri: highestParent, parentHeight: params.parentHeight, depth: 0 }, - await ctx.serviceAuthHeaders(requester), + const parentsRes = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getPostThread( + { uri: highestParent, parentHeight: params.parentHeight, depth: 0 }, + await ctx.serviceAuthHeaders(requester), + ), ) thread.parent = parentsRes.data.thread } catch (err) { diff --git a/packages/pds/src/api/app/bsky/feed/getPosts.ts b/packages/pds/src/api/app/bsky/feed/getPosts.ts index d1f3c59e570..aa69d1c1208 100644 --- a/packages/pds/src/api/app/bsky/feed/getPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/getPosts.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getPosts({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getPosts( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getPosts( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts index a2a393cf929..8db332c508c 100644 --- a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getRepostedBy({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getRepostedBy( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getRepostedBy( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts index d08af9e8363..9ae3ae47227 100644 --- a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getSuggestedFeeds({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getSuggestedFeeds( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getSuggestedFeeds( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/feed/getTimeline.ts b/packages/pds/src/api/app/bsky/feed/getTimeline.ts index 107e9ca6b54..214ec168129 100644 --- a/packages/pds/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/pds/src/api/app/bsky/feed/getTimeline.ts @@ -3,7 +3,12 @@ import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/app/bsky/feed/getTimeline' import { handleReadAfterWrite } from '../util/read-after-write' import { LocalRecords } from '../../../../services/local' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getTimeline({ @@ -25,9 +30,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.getTimeline( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getTimeline( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return await handleReadAfterWrite(ctx, requester, res, getTimelineMunge) }, diff --git a/packages/pds/src/api/app/bsky/graph/getBlocks.ts b/packages/pds/src/api/app/bsky/graph/getBlocks.ts index 9b79a7b6438..0b7bedbc246 100644 --- a/packages/pds/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getBlocks.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getBlocks({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.graph.getBlocks( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getBlocks( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getFollowers.ts b/packages/pds/src/api/app/bsky/graph/getFollowers.ts index 00146a41ce3..e2c763882ec 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollowers.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollowers({ @@ -25,9 +30,13 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.type === 'access' ? auth.credentials.did : null - const res = await ctx.appViewAgent.api.app.bsky.graph.getFollowers( - params, - requester ? await ctx.serviceAuthHeaders(requester) : authPassthru(req), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getFollowers( + params, + requester + ? await ctx.serviceAuthHeaders(requester) + : authPassthru(req), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getFollows.ts b/packages/pds/src/api/app/bsky/graph/getFollows.ts index ea9a074c1a6..f0aa7655e49 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollows.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollows.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getFollows({ @@ -25,9 +30,13 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.type === 'access' ? auth.credentials.did : null - const res = await ctx.appViewAgent.api.app.bsky.graph.getFollows( - params, - requester ? await ctx.serviceAuthHeaders(requester) : authPassthru(req), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getFollows( + params, + requester + ? await ctx.serviceAuthHeaders(requester) + : authPassthru(req), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getList.ts b/packages/pds/src/api/app/bsky/graph/getList.ts index 606cd13d85d..3b826859351 100644 --- a/packages/pds/src/api/app/bsky/graph/getList.ts +++ b/packages/pds/src/api/app/bsky/graph/getList.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getList({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.graph.getList( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getList( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts index bae0bc78d98..2b0425f3b1f 100644 --- a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListBlocks({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.graph.getListBlocks( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getListBlocks( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getListMutes.ts b/packages/pds/src/api/app/bsky/graph/getListMutes.ts index 9fb05489018..f62e27771d3 100644 --- a/packages/pds/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getListMutes.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListMutes({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.graph.getListMutes( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getListMutes( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getLists.ts b/packages/pds/src/api/app/bsky/graph/getLists.ts index d34eb1138e9..b2a1b401186 100644 --- a/packages/pds/src/api/app/bsky/graph/getLists.ts +++ b/packages/pds/src/api/app/bsky/graph/getLists.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getLists({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.graph.getLists( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getLists( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getMutes.ts b/packages/pds/src/api/app/bsky/graph/getMutes.ts index 72035036ccd..fba3afe09c1 100644 --- a/packages/pds/src/api/app/bsky/graph/getMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getMutes.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getMutes({ @@ -22,9 +27,11 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.graph.getMutes( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getMutes( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index 89851e0dfe3..1a8993bf90e 100644 --- a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getSuggestedFollowsByActor({ @@ -23,11 +28,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = - await ctx.appViewAgent.api.app.bsky.graph.getSuggestedFollowsByActor( + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.getSuggestedFollowsByActor( params, await ctx.serviceAuthHeaders(requester), - ) + ), + ) return { encoding: 'application/json', body: res.data, diff --git a/packages/pds/src/api/app/bsky/graph/muteActor.ts b/packages/pds/src/api/app/bsky/graph/muteActor.ts index ac19702f0d4..e0059b58e38 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActor.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, proxy, proxyAppView } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActor({ @@ -21,10 +21,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - await ctx.appViewAgent.api.app.bsky.graph.muteActor(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), - encoding: 'application/json', - }) + await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.muteActor(input.body, { + ...(await ctx.serviceAuthHeaders(requester)), + encoding: 'application/json', + }), + ) }, }) } diff --git a/packages/pds/src/api/app/bsky/graph/muteActorList.ts b/packages/pds/src/api/app/bsky/graph/muteActorList.ts index 249f0aa7606..8c881e4f8a9 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActorList.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, proxy, proxyAppView } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.muteActorList({ @@ -21,10 +21,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - await ctx.appViewAgent.api.app.bsky.graph.muteActorList(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), - encoding: 'application/json', - }) + await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.muteActorList(input.body, { + ...(await ctx.serviceAuthHeaders(requester)), + encoding: 'application/json', + }), + ) }, }) } diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts index c60cfc7d02f..427a47219f5 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, proxy, proxyAppView } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActor({ @@ -21,10 +21,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - await ctx.appViewAgent.api.app.bsky.graph.unmuteActor(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), - encoding: 'application/json', - }) + await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.unmuteActor(input.body, { + ...(await ctx.serviceAuthHeaders(requester)), + encoding: 'application/json', + }), + ) }, }) } diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts index 96ab8bfe08c..a6a770cf05e 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, proxy, proxyAppView } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.unmuteActorList({ @@ -21,10 +21,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - await ctx.appViewAgent.api.app.bsky.graph.unmuteActorList(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), - encoding: 'application/json', - }) + await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.graph.unmuteActorList(input.body, { + ...(await ctx.serviceAuthHeaders(requester)), + encoding: 'application/json', + }), + ) }, }) } diff --git a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts index 210f9ddda81..96bab0f01f9 100644 --- a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.getUnreadCount({ @@ -22,11 +27,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = - await ctx.appViewAgent.api.app.bsky.notification.getUnreadCount( + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.notification.getUnreadCount( params, await ctx.serviceAuthHeaders(requester), - ) + ), + ) return { encoding: 'application/json', body: res.data, diff --git a/packages/pds/src/api/app/bsky/notification/listNotifications.ts b/packages/pds/src/api/app/bsky/notification/listNotifications.ts index 95618dbeeb6..2b0bdf3266e 100644 --- a/packages/pds/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/pds/src/api/app/bsky/notification/listNotifications.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.listNotifications({ @@ -23,11 +28,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = - await ctx.appViewAgent.api.app.bsky.notification.listNotifications( + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.notification.listNotifications( params, await ctx.serviceAuthHeaders(requester), - ) + ), + ) return { encoding: 'application/json', body: res.data, diff --git a/packages/pds/src/api/app/bsky/notification/registerPush.ts b/packages/pds/src/api/app/bsky/notification/registerPush.ts index bb7286a1485..97fd40a8b7b 100644 --- a/packages/pds/src/api/app/bsky/notification/registerPush.ts +++ b/packages/pds/src/api/app/bsky/notification/registerPush.ts @@ -4,7 +4,7 @@ import { getNotif } from '@atproto/identity' import { InvalidRequestError } from '@atproto/xrpc-server' import { AtpAgent } from '@atproto/api' import { getDidDoc } from '../util/resolver' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, proxy, proxyAppView } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.registerPush({ @@ -32,12 +32,11 @@ export default function (server: Server, ctx: AppContext) { const authHeaders = await ctx.serviceAuthHeaders(did, serviceDid) if (ctx.cfg.bskyAppView.did === serviceDid) { - await ctx.appViewAgent.api.app.bsky.notification.registerPush( - input.body, - { + await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.notification.registerPush(input.body, { ...authHeaders, encoding: 'application/json', - }, + }), ) return } diff --git a/packages/pds/src/api/app/bsky/notification/updateSeen.ts b/packages/pds/src/api/app/bsky/notification/updateSeen.ts index 454bcc44038..49f93096ab8 100644 --- a/packages/pds/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/pds/src/api/app/bsky/notification/updateSeen.ts @@ -1,6 +1,6 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, proxy, proxyAppView } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.updateSeen({ @@ -21,10 +21,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - await ctx.appViewAgent.api.app.bsky.notification.updateSeen(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), - encoding: 'application/json', - }) + await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.notification.updateSeen(input.body, { + ...(await ctx.serviceAuthHeaders(requester)), + encoding: 'application/json', + }), + ) }, }) } diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopular.ts b/packages/pds/src/api/app/bsky/unspecced/getPopular.ts index a924f48b4ae..8461457b4ac 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopular.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopular.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { @@ -26,9 +31,11 @@ export default function (server: Server, ctx: AppContext) { const HOT_CLASSIC_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/hot-classic' const HOT_CLASSIC_DID = 'did:plc:5fllqkujj6kqp5izd5jg7gox' - const res = await ctx.appViewAgent.api.app.bsky.feed.getFeed( - { feed: HOT_CLASSIC_URI, limit: params.limit, cursor: params.cursor }, - await ctx.serviceAuthHeaders(requester, HOT_CLASSIC_DID), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.getFeed( + { feed: HOT_CLASSIC_URI, limit: params.limit, cursor: params.cursor }, + await ctx.serviceAuthHeaders(requester, HOT_CLASSIC_DID), + ), ) return { encoding: 'application/json', diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index 4204c2673ea..f6a63c5d92d 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { @@ -24,11 +29,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const res = - await ctx.appViewAgent.api.app.bsky.unspecced.getPopularFeedGenerators( + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.unspecced.getPopularFeedGenerators( params, await ctx.serviceAuthHeaders(requester), - ) + ), + ) return { encoding: 'application/json', body: res.data, diff --git a/packages/pds/src/api/com/atproto/moderation/createReport.ts b/packages/pds/src/api/com/atproto/moderation/createReport.ts index 2cea4a395b1..f2cb05a95e1 100644 --- a/packages/pds/src/api/com/atproto/moderation/createReport.ts +++ b/packages/pds/src/api/com/atproto/moderation/createReport.ts @@ -1,6 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, proxy, resultPassthru } from '../../../proxy' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ @@ -22,11 +27,12 @@ export default function (server: Server, ctx: AppContext) { } const requester = auth.credentials.did - const { data: result } = - await ctx.appViewAgent.com.atproto.moderation.createReport(input.body, { + const { data: result } = await proxyAppView(ctx, async (agent) => + agent.com.atproto.moderation.createReport(input.body, { ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', - }) + }), + ) return { encoding: 'application/json', body: result, diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index c4dea6cf27f..6a20e523ff0 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -120,12 +120,13 @@ export default function (server: Server, ctx: AppContext) { return { did, + pdsDid: entrywayAssignedPds?.did ?? null, accessJwt: access, refreshJwt: refresh, } }) - const didDoc = await didDocForSession(ctx, result.did, true) + const didDoc = await didDocForSession(ctx, result) return { encoding: 'application/json', diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index fffd6093928..df1d2e2d68b 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -61,7 +61,7 @@ export default function (server: Server, ctx: AppContext) { pdsDid: user.pdsDid, appPasswordName, }), - didDocForSession(ctx, user.did), + didDocForSession(ctx, user), ]) return { diff --git a/packages/pds/src/api/com/atproto/server/getSession.ts b/packages/pds/src/api/com/atproto/server/getSession.ts index bf271a9c02e..e798c48a1b9 100644 --- a/packages/pds/src/api/com/atproto/server/getSession.ts +++ b/packages/pds/src/api/com/atproto/server/getSession.ts @@ -8,11 +8,14 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.access, handler: async ({ auth }) => { const did = auth.credentials.did - const [user, didDoc] = await Promise.all([ + const [account, didDoc] = await Promise.all([ ctx.services.account(ctx.db).getAccount(did), - didDocForSession(ctx, did), + didDocForSession(ctx, { + did, + pdsDid: auth.credentials.audience ?? null, + }), ]) - if (!user) { + if (!account) { throw new InvalidRequestError( `Could not find user info for account: ${did}`, ) @@ -20,11 +23,11 @@ export default function (server: Server, ctx: AppContext) { return { encoding: 'application/json', body: { - handle: user.handle, - did: user.did, + handle: account.handle, + did: account.did, didDoc, - email: user.email, - emailConfirmed: !!user.emailConfirmedAt, + email: account.email, + emailConfirmed: !!account.emailConfirmedAt, }, } }, diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 598873bdd0f..1b0c190707b 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { } const [didDoc, rotated] = await Promise.all([ - didDocForSession(ctx, user.did), + didDocForSession(ctx, user), ctx.db.transaction((dbTxn) => { return ctx.services.auth(dbTxn).rotateRefreshToken({ id: auth.credentials.tokenId, diff --git a/packages/pds/src/api/com/atproto/server/util.ts b/packages/pds/src/api/com/atproto/server/util.ts index b110f30f3ff..575210029c9 100644 --- a/packages/pds/src/api/com/atproto/server/util.ts +++ b/packages/pds/src/api/com/atproto/server/util.ts @@ -29,21 +29,33 @@ export const getRandomToken = () => { export const didDocForSession = async ( ctx: AppContext, - did: string, - forceRefresh?: boolean, + account: { did: string; pdsDid: string | null }, ): Promise => { - if (!ctx.cfg.identity.enableDidDocWithSession) return + if (!ctx.cfg.identity.enableDidDocWithSession || account.pdsDid === null) { + return + } try { - const [didDoc, pdses] = await Promise.all([ - ctx.idResolver.did.resolve(did, forceRefresh), - ctx.services.account(ctx.db).getPdses({ cached: true }), + const [didDoc, pds] = await Promise.all([ + ctx.idResolver.did.resolve(account.did), + ctx.services.account(ctx.db).getPds(account.pdsDid, { cached: true }), ]) - if (!didDoc) return - const pdsEndpoint = getPdsEndpoint(didDoc) - const pdsHost = pdsEndpoint && new URL(pdsEndpoint).host - if (!pdses.some((pds) => pds.host === pdsHost)) return - return didDoc + if (!didDoc || !pds) return + if (getPdsHost(didDoc) === pds.host) { + return didDoc + } + // no pds match, try again with fresh did doc + const freshDidDoc = await ctx.idResolver.did.resolve(account.did, true) + if (!freshDidDoc) return + if (getPdsHost(freshDidDoc) === pds.host) { + return didDoc + } } catch (err) { - dbLogger.warn({ err, did }, 'failed to resolve did doc') + dbLogger.warn({ err, did: account.did }, 'failed to resolve did doc') } } + +const getPdsHost = (didDoc: DidDocument) => { + const pdsEndpoint = getPdsEndpoint(didDoc) + if (!pdsEndpoint) return + return new URL(pdsEndpoint).host +} diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index a93e0ff4bdc..7c3cc78485d 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -25,7 +25,6 @@ export const proxy = async ( try { return await fn(ctx.pdsAgents.get(pds.host)) } catch (err) { - // @TODO may need to pass through special lexicon errors if ( err instanceof XRPCError && err.status === 403 && @@ -41,6 +40,28 @@ export const proxy = async ( } } +export const proxyAppView = async ( + ctx: AppContext, + fn: (agent: AtpAgent) => Promise, +): Promise => { + try { + return await fn(ctx.appViewAgent) + } catch (err) { + if ( + err instanceof XRPCError && + err.status === 401 && + err.error === 'BadJwtSignature' + ) { + // instruct client to refresh token during potential account migration + throw new InvalidRequestError( + 'Service token issuer is out of date', + 'ExpiredToken', + ) + } + throw err + } +} + export const getStreamingRequestInit = (body: Readable): RequestInit => { const reqInit: RequestInit & { duplex: string } = { body: Readable.toWeb(body), From cca9f917a771fa1665cd7f2a7917e1137e74a2af Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 7 Nov 2023 15:20:09 -0500 Subject: [PATCH 037/135] Entryway-to-pds account deletion flow (#1820) * add admin lexicon for account deletion * handle entryway-to-pds account deletion flow * ensure pds before creating report or putting preferences * tidy * better guarantee that acct deletion makes it out to pds behind entryway --- lexicons/com/atproto/admin/deleteAccount.json | 20 ++++++ packages/api/src/client/index.ts | 13 ++++ packages/api/src/client/lexicons.ts | 24 +++++++ .../types/com/atproto/admin/deleteAccount.ts | 32 +++++++++ packages/bsky/src/lexicon/index.ts | 12 ++++ packages/bsky/src/lexicon/lexicons.ts | 24 +++++++ .../types/com/atproto/admin/deleteAccount.ts | 38 +++++++++++ .../src/api/app/bsky/actor/putPreferences.ts | 4 +- .../com/atproto/moderation/createReport.ts | 3 + .../api/com/atproto/server/deleteAccount.ts | 65 +++++++++++++++---- packages/pds/src/lexicon/index.ts | 12 ++++ packages/pds/src/lexicon/lexicons.ts | 24 +++++++ .../types/com/atproto/admin/deleteAccount.ts | 38 +++++++++++ packages/pds/src/services/account/index.ts | 3 + packages/pds/src/util/retry.ts | 65 +++++++++++++++++++ .../pds/tests/sync/subscribe-repos.test.ts | 2 + 16 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 lexicons/com/atproto/admin/deleteAccount.json create mode 100644 packages/api/src/client/types/com/atproto/admin/deleteAccount.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/deleteAccount.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/deleteAccount.ts create mode 100644 packages/pds/src/util/retry.ts diff --git a/lexicons/com/atproto/admin/deleteAccount.json b/lexicons/com/atproto/admin/deleteAccount.json new file mode 100644 index 00000000000..bd7532cef61 --- /dev/null +++ b/lexicons/com/atproto/admin/deleteAccount.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.deleteAccount", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a user account as an administrator.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 3fd82222639..fe64ab61f30 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -8,6 +8,7 @@ import { import { schemas } from './lexicons' import { CID } from 'multiformats/cid' import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' +import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -145,6 +146,7 @@ import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecce import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' +export * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' export * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -369,6 +371,17 @@ export class AdminNS { this._service = service } + deleteAccount( + data?: ComAtprotoAdminDeleteAccount.InputSchema, + opts?: ComAtprotoAdminDeleteAccount.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.deleteAccount', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoAdminDeleteAccount.toKnownErr(e) + }) + } + disableAccountInvites( data?: ComAtprotoAdminDisableAccountInvites.InputSchema, opts?: ComAtprotoAdminDisableAccountInvites.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index f17885819a0..d3e78cff850 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -710,6 +710,29 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteAccount: { + lexicon: 1, + id: 'com.atproto.admin.deleteAccount', + defs: { + main: { + type: 'procedure', + description: 'Delete a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -7574,6 +7597,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', diff --git a/packages/api/src/client/types/com/atproto/admin/deleteAccount.ts b/packages/api/src/client/types/com/atproto/admin/deleteAccount.ts new file mode 100644 index 00000000000..b8b5aa511b8 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/deleteAccount.ts @@ -0,0 +1,32 @@ +/** + * 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 { + did: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index bf69ebafa68..bf2b35cb678 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -9,6 +9,7 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -195,6 +196,17 @@ export class AdminNS { this._server = server } + deleteAccount( + cfg: ConfigOf< + AV, + ComAtprotoAdminDeleteAccount.Handler>, + ComAtprotoAdminDeleteAccount.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.deleteAccount' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + disableAccountInvites( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index f17885819a0..d3e78cff850 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -710,6 +710,29 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteAccount: { + lexicon: 1, + id: 'com.atproto.admin.deleteAccount', + defs: { + main: { + type: 'procedure', + description: 'Delete a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -7574,6 +7597,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/deleteAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/deleteAccount.ts new file mode 100644 index 00000000000..13e68eb5c7d --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/deleteAccount.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + did: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +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/api/app/bsky/actor/putPreferences.ts b/packages/pds/src/api/app/bsky/actor/putPreferences.ts index e9f9e669c96..415fbc7b26b 100644 --- a/packages/pds/src/api/app/bsky/actor/putPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/putPreferences.ts @@ -2,7 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { UserPreference } from '../../../../services/account' import { InvalidRequestError } from '@atproto/xrpc-server' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, ensureThisPds, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.putPreferences({ @@ -22,6 +22,8 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const { preferences } = input.body const requester = auth.credentials.did const { services, db } = ctx diff --git a/packages/pds/src/api/com/atproto/moderation/createReport.ts b/packages/pds/src/api/com/atproto/moderation/createReport.ts index f2cb05a95e1..79abd286a71 100644 --- a/packages/pds/src/api/com/atproto/moderation/createReport.ts +++ b/packages/pds/src/api/com/atproto/moderation/createReport.ts @@ -2,6 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { authPassthru, + ensureThisPds, proxy, proxyAppView, resultPassthru, @@ -26,6 +27,8 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const requester = auth.credentials.did const { data: result } = await proxyAppView(ctx, async (agent) => agent.com.atproto.moderation.createReport(input.body, { diff --git a/packages/pds/src/api/com/atproto/server/deleteAccount.ts b/packages/pds/src/api/com/atproto/server/deleteAccount.ts index c57d197c295..9141cea6f2d 100644 --- a/packages/pds/src/api/com/atproto/server/deleteAccount.ts +++ b/packages/pds/src/api/com/atproto/server/deleteAccount.ts @@ -1,11 +1,16 @@ -import { AuthRequiredError } from '@atproto/xrpc-server' +import { MINUTE } from '@atproto/common' +import { + AuthRequiredError, + InvalidRequestError, + UpstreamFailureError, +} from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { MINUTE } from '@atproto/common' +import { isThisPds } from '../../../proxy' +import { retryHttp } from '../../../../util/retry' const REASON_ACCT_DELETION = 'account_deletion' -// @TODO negotiate account deletions between pds and entryway export default function (server: Server, ctx: AppContext) { server.com.atproto.server.deleteAccount({ rateLimit: { @@ -14,19 +19,24 @@ export default function (server: Server, ctx: AppContext) { }, handler: async ({ input, req }) => { const { did, password, token } = input.body - const validPass = await ctx.services - .account(ctx.db) - .verifyAccountPassword(did, password) + const accountService = ctx.services.account(ctx.db) + const validPass = await accountService.verifyAccountPassword( + did, + password, + ) if (!validPass) { throw new AuthRequiredError('Invalid did or password') } - await ctx.services - .account(ctx.db) - .assertValidToken(did, 'delete_account', token) + const account = await accountService.getAccount(did, true) + if (!account) { + throw new InvalidRequestError('account not found', 'AccountNotFound') + } + + await accountService.assertValidToken(did, 'delete_account', token) await ctx.db.transaction(async (dbTxn) => { - const accountService = ctx.services.account(dbTxn) + const accountTxn = ctx.services.account(dbTxn) const moderationTxn = ctx.services.moderation(dbTxn) const currState = await moderationTxn.getRepoTakedownState(did) // Do not disturb an existing takedown, continue with account deletion @@ -36,15 +46,46 @@ export default function (server: Server, ctx: AppContext) { ref: REASON_ACCT_DELETION, }) } - await accountService.deleteEmailToken(did, 'delete_account') + await accountTxn.deleteEmailToken(did, 'delete_account') }) + const { pdsDid } = account + if (ctx.cfg.service.isEntryway && pdsDid && !isThisPds(ctx, pdsDid)) { + try { + const pds = await accountService.getPds(pdsDid, { cached: true }) + if (!pds) { + throw new UpstreamFailureError('unknown pds') + } + // both entryway and pds behind it need to clean-up account state, then pds sequences tombstone. + // the long flow is: pds(server.deleteAccount) -> entryway(server.deleteAccount) -> pds(admin.deleteAccount) + const agent = ctx.pdsAgents.get(pds.host) + await retryHttp(() => + agent.com.atproto.admin.deleteAccount( + { did }, + { + encoding: 'application/json', + headers: ctx.authVerifier.createAdminRoleHeaders(), + }, + ), + ) + } catch (err) { + req.log.error( + { did, pdsDid, err }, + 'account deletion failed on pds behind entryway', + ) + } + } + ctx.backgroundQueue.add(async (db) => { + // in the background perform the hard account deletion work try { - // In the background perform the hard account deletion work await ctx.services.record(db).deleteForActor(did) await ctx.services.repo(db).deleteRepo(did) await ctx.services.account(db).deleteAccount(did) + if (!ctx.cfg.service.isEntryway || isThisPds(ctx, pdsDid)) { + // if this is the user's pds sequence the tombstone, otherwise taken care of by their pds behind the entryway. + await ctx.services.account(db).sequenceTombstone(did) + } } catch (err) { req.log.error({ did, err }, 'account deletion failed') } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index bf69ebafa68..bf2b35cb678 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -9,6 +9,7 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -195,6 +196,17 @@ export class AdminNS { this._server = server } + deleteAccount( + cfg: ConfigOf< + AV, + ComAtprotoAdminDeleteAccount.Handler>, + ComAtprotoAdminDeleteAccount.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.deleteAccount' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + disableAccountInvites( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index f17885819a0..d3e78cff850 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -710,6 +710,29 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteAccount: { + lexicon: 1, + id: 'com.atproto.admin.deleteAccount', + defs: { + main: { + type: 'procedure', + description: 'Delete a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -7574,6 +7597,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/deleteAccount.ts b/packages/pds/src/lexicon/types/com/atproto/admin/deleteAccount.ts new file mode 100644 index 00000000000..13e68eb5c7d --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/deleteAccount.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + did: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +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/services/account/index.ts b/packages/pds/src/services/account/index.ts index 749435a0ed1..e5479e3af65 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -385,6 +385,9 @@ export class AccountService { .deleteFrom('did_handle') .where('did_handle.did', '=', did) .execute() + } + + async sequenceTombstone(did: string): Promise { const seqEvt = await sequencer.formatSeqTombstone(did) await this.db.transaction(async (txn) => { await sequencer.sequenceEvt(txn, seqEvt) diff --git a/packages/pds/src/util/retry.ts b/packages/pds/src/util/retry.ts new file mode 100644 index 00000000000..ad3c44ee08f --- /dev/null +++ b/packages/pds/src/util/retry.ts @@ -0,0 +1,65 @@ +// @NOTE nabbed from @atproto/bsky utils. not DRYing it up now to avoid big fork between main and entryway. +import { wait } from '@atproto/common' +import { ResponseType, XRPCError } from '@atproto/xrpc' + +export async function retry( + fn: () => Promise, + opts: RetryOptions = {}, +): Promise { + const { max = 3, retryable = () => true } = opts + let retries = 0 + let doneError: unknown + while (!doneError) { + try { + if (retries) await backoff(retries) + return await fn() + } catch (err) { + const willRetry = retries < max && retryable(err) + if (!willRetry) doneError = err + retries += 1 + } + } + throw doneError +} + +export async function retryHttp( + fn: () => Promise, + opts: RetryOptions = {}, +): Promise { + return retry(fn, { retryable: retryableHttp, ...opts }) +} + +export function retryableHttp(err: unknown) { + if (err instanceof XRPCError) { + if (err.status === ResponseType.Unknown) return true + return retryableHttpStatusCodes.has(err.status) + } + return false +} + +const retryableHttpStatusCodes = new Set([ + 408, 425, 429, 500, 502, 503, 504, 522, 524, +]) + +type RetryOptions = { + max?: number + retryable?: (err: unknown) => boolean +} + +// Waits exponential backoff with max and jitter: ~50, ~100, ~200, ~400, ~800, ~1000, ~1000, ... +async function backoff(n: number, multiplier = 50, max = 1000) { + const exponentialMs = Math.pow(2, n) * multiplier + const ms = Math.min(exponentialMs, max) + await wait(jitter(ms)) +} + +// Adds randomness +/-15% of value +function jitter(value: number) { + const delta = value * 0.15 + return value + randomRange(-delta, delta) +} + +function randomRange(from: number, to: number) { + const rand = Math.random() * (to - from) + return rand + from +} diff --git a/packages/pds/tests/sync/subscribe-repos.test.ts b/packages/pds/tests/sync/subscribe-repos.test.ts index 58745b7fe1e..5aa14024ff2 100644 --- a/packages/pds/tests/sync/subscribe-repos.test.ts +++ b/packages/pds/tests/sync/subscribe-repos.test.ts @@ -351,6 +351,7 @@ describe('repo subscribe repos', () => { await ctx.services.record(db).deleteForActor(did) await ctx.services.repo(db).deleteRepo(did) await ctx.services.account(db).deleteAccount(did) + await ctx.services.account(db).sequenceTombstone(did) } const ws = new WebSocket( @@ -381,6 +382,7 @@ describe('repo subscribe repos', () => { await ctx.services.record(db).deleteForActor(baddie3) await ctx.services.repo(db).deleteRepo(baddie3) await ctx.services.account(db).deleteAccount(baddie3) + await ctx.services.account(db).sequenceTombstone(baddie3) const ws = new WebSocket( `ws://${serverHost}/xrpc/com.atproto.sync.subscribeRepos?cursor=${-1}`, From 5630a12c1f074546783a60808e2f672279809034 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 14 Nov 2023 22:20:12 -0600 Subject: [PATCH 038/135] correctly proxy feed.searchPosts --- .../pds/src/api/app/bsky/feed/searchPosts.ts | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/pds/src/api/app/bsky/feed/searchPosts.ts b/packages/pds/src/api/app/bsky/feed/searchPosts.ts index 85384751ea1..28811ee8334 100644 --- a/packages/pds/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/searchPosts.ts @@ -1,14 +1,37 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.searchPosts({ auth: ctx.authVerifier.access, - handler: async ({ params, auth }) => { + handler: async ({ params, auth, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = await agent.api.app.bsky.feed.searchPosts( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + const requester = auth.credentials.did - const res = await ctx.appViewAgent.api.app.bsky.feed.searchPosts( - params, - await ctx.serviceAuthHeaders(requester), + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.feed.searchPosts( + params, + await ctx.serviceAuthHeaders(requester), + ), ) return { encoding: 'application/json', From 2b0598009f68f144b5c6e53c6d676819a724797d Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 21 Nov 2023 18:08:13 -0500 Subject: [PATCH 039/135] Test entryway session handling (#1818) test entryway session handling --- packages/pds/tests/entryway.test.ts | 124 +++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts index f4f56a7331e..40ef7b191d5 100644 --- a/packages/pds/tests/entryway.test.ts +++ b/packages/pds/tests/entryway.test.ts @@ -3,7 +3,9 @@ import fs from 'node:fs/promises' import * as ui8 from 'uint8arrays' import * as jose from 'jose' import AtpAgent from '@atproto/api' +import { XRPCError } from '@atproto/xrpc' import { Secp256k1Keypair } from '@atproto/crypto' +import { getPdsEndpoint, isValidDidDoc } from '@atproto/common' import { SeedClient, TestPds, @@ -11,7 +13,6 @@ import { mockNetworkUtilities, } from '@atproto/dev-env' import { ids } from '@atproto/api/src/client/lexicons' -import { getPdsEndpoint, isValidDidDoc } from '@atproto/common' describe('entryway', () => { let plc: TestPlc @@ -492,4 +493,125 @@ describe('entryway', () => { }) expect(profile.value['displayName']).toEqual('Carol!') }) + + describe('agent sessions', () => { + let dan: string + + it('updates service url on account creation based on did doc.', async () => { + await entryway.ctx.db.db.updateTable('pds').set({ weight: 1 }).execute() + const agent = new AtpAgent({ service: entryway.url }) + const { data: session } = await agent.createAccount({ + email: 'dan@test.com', + handle: 'dan.test', + password: 'test123', + }) + dan = session.did + await entryway.ctx.db.db.updateTable('pds').set({ weight: 0 }).execute() + expect(session.handle).toBe('dan.test') + assert(isValidDidDoc(session.didDoc)) + expect(session.didDoc.alsoKnownAs).toEqual(['at://dan.test']) + expect(getPdsEndpoint(session.didDoc)).toBe(pds.url) + expect(agent.api.xrpc.uri.origin).toBe(pds.url) + }) + + it('updates service url on session creation, resumption based on did doc.', async () => { + // creation + const agent1 = new AtpAgent({ service: entryway.url }) + expect(agent1.api.xrpc.uri.origin).toBe(entryway.url) + const { data: session } = await agent1.login({ + identifier: 'dan.test', + password: 'test123', + }) + assert(agent1.session) + const agentSession = agent1.session + expect(session.handle).toBe('dan.test') + assert(isValidDidDoc(session.didDoc)) + expect(session.didDoc.alsoKnownAs).toEqual(['at://dan.test']) + expect(getPdsEndpoint(session.didDoc)).toBe(pds.url) + expect(agent1.api.xrpc.uri.origin).toBe(pds.url) + // resumption + const agent2 = new AtpAgent({ service: entryway.url }) + expect(agent2.api.xrpc.uri.origin).toBe(entryway.url) + const { data: resumedSession } = await agent2.resumeSession(agentSession) + expect(resumedSession.handle).toBe('dan.test') + assert(isValidDidDoc(resumedSession.didDoc)) + expect(resumedSession.didDoc.alsoKnownAs).toEqual(['at://dan.test']) + expect(getPdsEndpoint(resumedSession.didDoc)).toBe(pds.url) + expect(agent2.api.xrpc.uri.origin).toBe(pds.url) + }) + + it('updates service url on session refresh based on did doc.', async () => { + const agent = new AtpAgent({ service: entryway.url }) + const authService = entryway.ctx.services.auth(entryway.ctx.db) + const { refresh: refreshToken } = await authService.createSession({ + did: dan, + pdsDid: pds.ctx.cfg.service.did, + appPasswordName: null, + }) + const expiredAccessToken = await authService.createAccessToken({ + did: dan, + pdsDid: pds.ctx.cfg.service.did, + expiresIn: -1, + }) + agent.session = { + did: dan, + handle: 'dan.test', + accessJwt: expiredAccessToken, + refreshJwt: refreshToken, + } + expect(agent.api.xrpc.uri.origin).toBe(entryway.url) + // since access token is expired, will cause a refresh flow + await agent.com.atproto.server.getAccountInviteCodes() + expect(agent.api.xrpc.uri.origin).toBe(pds.url) + expect(agent.session).toBeDefined() + expect(agent.session.accessJwt).not.toBe(expiredAccessToken) + expect(agent.session.refreshJwt).not.toBe(refreshToken) + }) + + it('initiates token refresh when pds sees unknown user.', async () => { + const { data: session } = + await entrywayAgent.api.com.atproto.server.createAccount({ + email: 'eve@test.com', + handle: 'eve.test', + password: 'test123', + }) + const authService = entryway.ctx.services.auth(entryway.ctx.db) + const tokens = await authService.createSession({ + did: session.did, + pdsDid: pds.ctx.cfg.service.did, // not eve's pds + appPasswordName: null, + }) + const attempt = (agent: AtpAgent) => + agent.api.app.bsky.actor.putPreferences( + { preferences: [] }, + { + headers: SeedClient.getHeaders(tokens.access), + encoding: 'application/json', + }, + ) + const pdsDirectErr = await attempt(pdsAgent).catch((err) => err) + const entrywayProxyErr = await attempt(entrywayAgent).catch((err) => err) + assert(pdsDirectErr instanceof XRPCError) + assert(entrywayProxyErr instanceof XRPCError) + expect(pdsDirectErr.status).toBe(403) + expect(pdsDirectErr.error).toBe('AccountNotFound') + expect(entrywayProxyErr.status).toBe(400) + expect(entrywayProxyErr.error).toBe('ExpiredToken') + // refresh handled by agent + const agent = new AtpAgent({ service: entryway.url }) + agent.session = { + did: session.did, + handle: session.handle, + accessJwt: tokens.access, + refreshJwt: tokens.refresh, + } + expect(agent.api.xrpc.uri.origin).toBe(entryway.url) + // since entryway serves an expiration error, will cause a refresh flow + await agent.api.app.bsky.actor.putPreferences({ preferences: [] }) + expect(agent.api.xrpc.uri.origin).toBe(entryway.url) + expect(agent.session).toBeDefined() + expect(agent.session.accessJwt).not.toBe(tokens.access) + expect(agent.session.refreshJwt).not.toBe(tokens.refresh) + }) + }) }) From 2fb1c8dfda27305b705430846eb840c6060de175 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Fri, 5 Jan 2024 18:32:11 -0500 Subject: [PATCH 040/135] Alter entryway for separate mod backend (#2019) * sync lexicons * update entryway config for mod service * update entryway api for mod service * update entryway tests for mod service * tidy, build * disable event reverser pds --------- Co-authored-by: dholms --- .github/workflows/build-and-push-pds-aws.yaml | 2 +- lexicons/app/bsky/feed/getAuthorFeed.json | 3 +- .../bsky/notification/listNotifications.json | 3 +- lexicons/com/atproto/admin/defs.json | 27 ++- .../com/atproto/admin/getAccountInfos.json | 36 +++ .../admin/queryModerationStatuses.json | 4 + lexicons/com/atproto/admin/sendEmail.json | 6 +- lexicons/com/atproto/moderation/defs.json | 7 +- lexicons/com/atproto/temp/importRepo.json | 27 +++ lexicons/com/atproto/temp/pushBlob.json | 24 ++ .../com/atproto/temp/transferAccount.json | 44 ++++ packages/api/src/client/index.ts | 53 +++++ packages/api/src/client/lexicons.ts | 223 ++++++++++++++++++ .../types/app/bsky/feed/getAuthorFeed.ts | 1 + .../bsky/notification/listNotifications.ts | 1 + .../client/types/com/atproto/admin/defs.ts | 29 +++ .../com/atproto/admin/getAccountInfos.ts | 36 +++ .../atproto/admin/queryModerationStatuses.ts | 2 + .../types/com/atproto/admin/sendEmail.ts | 2 + .../types/com/atproto/moderation/defs.ts | 3 + .../types/com/atproto/temp/importRepo.ts | 33 +++ .../client/types/com/atproto/temp/pushBlob.ts | 32 +++ .../types/com/atproto/temp/transferAccount.ts | 92 ++++++++ packages/bsky/src/lexicon/index.ts | 51 ++++ packages/bsky/src/lexicon/lexicons.ts | 223 ++++++++++++++++++ .../types/app/bsky/feed/getAuthorFeed.ts | 1 + .../bsky/notification/listNotifications.ts | 1 + .../lexicon/types/com/atproto/admin/defs.ts | 29 +++ .../com/atproto/admin/getAccountInfos.ts | 46 ++++ .../atproto/admin/queryModerationStatuses.ts | 2 + .../types/com/atproto/admin/sendEmail.ts | 2 + .../types/com/atproto/moderation/defs.ts | 3 + .../types/com/atproto/temp/importRepo.ts | 45 ++++ .../types/com/atproto/temp/pushBlob.ts | 39 +++ .../types/com/atproto/temp/transferAccount.ts | 62 +++++ packages/dev-env/src/network.ts | 3 +- packages/dev-env/src/pds.ts | 2 + .../pds/src/api/app/bsky/actor/getProfile.ts | 2 +- .../pds/src/api/app/bsky/actor/getProfiles.ts | 2 +- .../src/api/app/bsky/actor/getSuggestions.ts | 2 +- .../src/api/app/bsky/actor/searchActors.ts | 2 +- .../app/bsky/actor/searchActorsTypeahead.ts | 2 +- .../src/api/app/bsky/feed/getActorFeeds.ts | 2 +- .../src/api/app/bsky/feed/getActorLikes.ts | 2 +- .../src/api/app/bsky/feed/getAuthorFeed.ts | 2 +- packages/pds/src/api/app/bsky/feed/getFeed.ts | 2 +- .../src/api/app/bsky/feed/getFeedGenerator.ts | 2 +- .../api/app/bsky/feed/getFeedGenerators.ts | 2 +- .../pds/src/api/app/bsky/feed/getLikes.ts | 2 +- .../pds/src/api/app/bsky/feed/getListFeed.ts | 2 +- .../src/api/app/bsky/feed/getPostThread.ts | 4 +- .../pds/src/api/app/bsky/feed/getPosts.ts | 2 +- .../src/api/app/bsky/feed/getRepostedBy.ts | 2 +- .../api/app/bsky/feed/getSuggestedFeeds.ts | 2 +- .../pds/src/api/app/bsky/feed/getTimeline.ts | 2 +- .../pds/src/api/app/bsky/feed/searchPosts.ts | 2 +- .../pds/src/api/app/bsky/graph/getBlocks.ts | 2 +- .../src/api/app/bsky/graph/getFollowers.ts | 2 +- .../pds/src/api/app/bsky/graph/getFollows.ts | 2 +- .../pds/src/api/app/bsky/graph/getList.ts | 2 +- .../src/api/app/bsky/graph/getListBlocks.ts | 2 +- .../src/api/app/bsky/graph/getListMutes.ts | 2 +- .../pds/src/api/app/bsky/graph/getLists.ts | 2 +- .../pds/src/api/app/bsky/graph/getMutes.ts | 2 +- .../bsky/graph/getSuggestedFollowsByActor.ts | 2 +- .../pds/src/api/app/bsky/graph/muteActor.ts | 2 +- .../src/api/app/bsky/graph/muteActorList.ts | 2 +- .../pds/src/api/app/bsky/graph/unmuteActor.ts | 2 +- .../src/api/app/bsky/graph/unmuteActorList.ts | 2 +- .../app/bsky/notification/getUnreadCount.ts | 2 +- .../bsky/notification/listNotifications.ts | 2 +- .../api/app/bsky/notification/updateSeen.ts | 2 +- .../unspecced/getPopularFeedGenerators.ts | 2 +- .../com/atproto/admin/emitModerationEvent.ts | 2 +- .../api/com/atproto/admin/getAccountInfo.ts | 5 +- .../com/atproto/admin/getModerationEvent.ts | 2 +- .../src/api/com/atproto/admin/getRecord.ts | 2 +- .../pds/src/api/com/atproto/admin/getRepo.ts | 2 +- .../api/com/atproto/admin/getSubjectStatus.ts | 4 +- .../atproto/admin/queryModerationEvents.ts | 2 +- .../atproto/admin/queryModerationStatuses.ts | 2 +- .../src/api/com/atproto/admin/searchRepos.ts | 2 +- .../com/atproto/admin/updateSubjectStatus.ts | 2 - .../com/atproto/moderation/createReport.ts | 15 +- packages/pds/src/auth-verifier.ts | 28 +-- packages/pds/src/config/config.ts | 24 +- packages/pds/src/config/env.ts | 10 +- packages/pds/src/context.ts | 22 +- packages/pds/src/lexicon/index.ts | 51 ++++ packages/pds/src/lexicon/lexicons.ts | 223 ++++++++++++++++++ .../types/app/bsky/feed/getAuthorFeed.ts | 1 + .../bsky/notification/listNotifications.ts | 1 + .../lexicon/types/com/atproto/admin/defs.ts | 29 +++ .../com/atproto/admin/getAccountInfos.ts | 46 ++++ .../atproto/admin/queryModerationStatuses.ts | 2 + .../types/com/atproto/admin/sendEmail.ts | 2 + .../types/com/atproto/moderation/defs.ts | 3 + .../types/com/atproto/temp/importRepo.ts | 45 ++++ .../types/com/atproto/temp/pushBlob.ts | 39 +++ .../types/com/atproto/temp/transferAccount.ts | 62 +++++ packages/pds/tests/admin-auth.test.ts | 145 ++++++++++++ packages/pds/tests/moderation.test.ts | 115 --------- services/pds/index.js | 14 -- 103 files changed, 1944 insertions(+), 231 deletions(-) create mode 100644 lexicons/com/atproto/admin/getAccountInfos.json create mode 100644 lexicons/com/atproto/temp/importRepo.json create mode 100644 lexicons/com/atproto/temp/pushBlob.json create mode 100644 lexicons/com/atproto/temp/transferAccount.json create mode 100644 packages/api/src/client/types/com/atproto/admin/getAccountInfos.ts create mode 100644 packages/api/src/client/types/com/atproto/temp/importRepo.ts create mode 100644 packages/api/src/client/types/com/atproto/temp/pushBlob.ts create mode 100644 packages/api/src/client/types/com/atproto/temp/transferAccount.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfos.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/temp/importRepo.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/temp/pushBlob.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/temp/transferAccount.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfos.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/temp/importRepo.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/temp/pushBlob.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/temp/transferAccount.ts create mode 100644 packages/pds/tests/admin-auth.test.ts diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 8febb8ce234..e654a50caed 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - multi-pds-auth + - multi-pds-auth-ozone env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/lexicons/app/bsky/feed/getAuthorFeed.json b/lexicons/app/bsky/feed/getAuthorFeed.json index 27dccf63e20..1939fa9a49d 100644 --- a/lexicons/app/bsky/feed/getAuthorFeed.json +++ b/lexicons/app/bsky/feed/getAuthorFeed.json @@ -22,7 +22,8 @@ "knownValues": [ "posts_with_replies", "posts_no_replies", - "posts_with_media" + "posts_with_media", + "posts_and_author_threads" ], "default": "posts_with_replies" } diff --git a/lexicons/app/bsky/notification/listNotifications.json b/lexicons/app/bsky/notification/listNotifications.json index 41f92cad4bc..ea74c5fba53 100644 --- a/lexicons/app/bsky/notification/listNotifications.json +++ b/lexicons/app/bsky/notification/listNotifications.json @@ -28,7 +28,8 @@ "notifications": { "type": "array", "items": { "type": "ref", "ref": "#notification" } - } + }, + "seenAt": { "type": "string", "format": "datetime" } } } } diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index dcded1387d3..55a8b32be53 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -69,7 +69,8 @@ "#modEventLabel", "#modEventAcknowledge", "#modEventEscalate", - "#modEventMute" + "#modEventMute", + "#modEventResolveAppeal" ] }, "subject": { @@ -167,9 +168,18 @@ "type": "string", "format": "datetime" }, + "lastAppealedAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp referencing when the author of the subject appealed a moderation action" + }, "takendown": { "type": "boolean" }, + "appealed": { + "type": "boolean", + "description": "True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators." + }, "suspendUntil": { "type": "string", "format": "datetime" @@ -284,6 +294,7 @@ "did": { "type": "string", "format": "did" }, "handle": { "type": "string", "format": "handle" }, "email": { "type": "string" }, + "relatedRecords": { "type": "array", "items": { "type": "unknown" } }, "indexedAt": { "type": "string", "format": "datetime" }, "invitedBy": { "type": "ref", @@ -469,6 +480,16 @@ } } }, + "modEventResolveAppeal": { + "type": "object", + "description": "Resolve appeal on a subject", + "properties": { + "comment": { + "type": "string", + "description": "Describe resolution." + } + } + }, "modEventComment": { "type": "object", "description": "Add a comment to a subject", @@ -557,6 +578,10 @@ "subjectLine": { "type": "string", "description": "The subject line of the email sent to the user." + }, + "comment": { + "type": "string", + "description": "Additional comment about the outgoing comm." } } } diff --git a/lexicons/com/atproto/admin/getAccountInfos.json b/lexicons/com/atproto/admin/getAccountInfos.json new file mode 100644 index 00000000000..45d97e08bd6 --- /dev/null +++ b/lexicons/com/atproto/admin/getAccountInfos.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.getAccountInfos", + "defs": { + "main": { + "type": "query", + "description": "Get details about some accounts.", + "parameters": { + "type": "params", + "required": ["dids"], + "properties": { + "dids": { + "type": "array", + "items": { "type": "string", "format": "did" } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["infos"], + "properties": { + "infos": { + "type": "array", + "items": { + "type": "ref", + "ref": "com.atproto.admin.defs#accountView" + } + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/queryModerationStatuses.json b/lexicons/com/atproto/admin/queryModerationStatuses.json index 98fec5bd642..e3e2a859bd2 100644 --- a/lexicons/com/atproto/admin/queryModerationStatuses.json +++ b/lexicons/com/atproto/admin/queryModerationStatuses.json @@ -64,6 +64,10 @@ "type": "boolean", "description": "Get subjects that were taken down" }, + "appealed": { + "type": "boolean", + "description": "Get subjects in unresolved appealed status" + }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/com/atproto/admin/sendEmail.json b/lexicons/com/atproto/admin/sendEmail.json index 8234460d1ba..4c33dcd8951 100644 --- a/lexicons/com/atproto/admin/sendEmail.json +++ b/lexicons/com/atproto/admin/sendEmail.json @@ -14,7 +14,11 @@ "recipientDid": { "type": "string", "format": "did" }, "content": { "type": "string" }, "subject": { "type": "string" }, - "senderDid": { "type": "string", "format": "did" } + "senderDid": { "type": "string", "format": "did" }, + "comment": { + "type": "string", + "description": "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers" + } } } }, diff --git a/lexicons/com/atproto/moderation/defs.json b/lexicons/com/atproto/moderation/defs.json index a06579a502e..b9e980df779 100644 --- a/lexicons/com/atproto/moderation/defs.json +++ b/lexicons/com/atproto/moderation/defs.json @@ -10,7 +10,8 @@ "com.atproto.moderation.defs#reasonMisleading", "com.atproto.moderation.defs#reasonSexual", "com.atproto.moderation.defs#reasonRude", - "com.atproto.moderation.defs#reasonOther" + "com.atproto.moderation.defs#reasonOther", + "com.atproto.moderation.defs#reasonAppeal" ] }, "reasonSpam": { @@ -36,6 +37,10 @@ "reasonOther": { "type": "token", "description": "Other: reports not falling under another report category" + }, + "reasonAppeal": { + "type": "token", + "description": "Appeal: appeal a previously taken moderation action" } } } diff --git a/lexicons/com/atproto/temp/importRepo.json b/lexicons/com/atproto/temp/importRepo.json new file mode 100644 index 00000000000..f06daa09d73 --- /dev/null +++ b/lexicons/com/atproto/temp/importRepo.json @@ -0,0 +1,27 @@ +{ + "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 new file mode 100644 index 00000000000..9babc8f8e43 --- /dev/null +++ b/lexicons/com/atproto/temp/pushBlob.json @@ -0,0 +1,24 @@ +{ + "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 new file mode 100644 index 00000000000..3cb2035ac0e --- /dev/null +++ b/lexicons/com/atproto/temp/transferAccount.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "com.atproto.temp.transferAccount", + "defs": { + "main": { + "type": "procedure", + "description": "Transfer an account.", + "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 78f7f291783..fb56cd251a0 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -14,6 +14,7 @@ import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/di import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' +import * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent' import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' @@ -77,6 +78,9 @@ import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOf import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' 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 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' @@ -150,6 +154,7 @@ export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/di export * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' export * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' +export * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos' export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' export * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent' export * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' @@ -213,6 +218,9 @@ export * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOf export * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' export * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' 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 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' @@ -291,6 +299,7 @@ export const COM_ATPROTO_MODERATION = { DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual', DefsReasonRude: 'com.atproto.moderation.defs#reasonRude', DefsReasonOther: 'com.atproto.moderation.defs#reasonOther', + DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal', } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', @@ -434,6 +443,17 @@ export class AdminNS { }) } + getAccountInfos( + params?: ComAtprotoAdminGetAccountInfos.QueryParams, + opts?: ComAtprotoAdminGetAccountInfos.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.getAccountInfos', params, undefined, opts) + .catch((e) => { + throw ComAtprotoAdminGetAccountInfos.toKnownErr(e) + }) + } + getInviteCodes( params?: ComAtprotoAdminGetInviteCodes.QueryParams, opts?: ComAtprotoAdminGetInviteCodes.CallOptions, @@ -1121,6 +1141,39 @@ export class TempNS { throw ComAtprotoTempFetchLabels.toKnownErr(e) }) } + + 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) + }) + } + + 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 3357674dc26..258d297c69e 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -102,6 +102,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#modEventAcknowledge', 'lex:com.atproto.admin.defs#modEventEscalate', 'lex:com.atproto.admin.defs#modEventMute', + 'lex:com.atproto.admin.defs#modEventResolveAppeal', ], }, subject: { @@ -237,9 +238,20 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + lastAppealedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the author of the subject appealed a moderation action', + }, takendown: { type: 'boolean', }, + appealed: { + type: 'boolean', + description: + 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -424,6 +436,12 @@ export const schemaDict = { email: { type: 'string', }, + relatedRecords: { + type: 'array', + items: { + type: 'unknown', + }, + }, indexedAt: { type: 'string', format: 'datetime', @@ -717,6 +735,16 @@ export const schemaDict = { }, }, }, + modEventResolveAppeal: { + type: 'object', + description: 'Resolve appeal on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe resolution.', + }, + }, + }, modEventComment: { type: 'object', description: 'Add a comment to a subject', @@ -816,6 +844,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + comment: { + type: 'string', + description: 'Additional comment about the outgoing comm.', + }, }, }, }, @@ -1020,6 +1052,45 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetAccountInfos: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfos', + defs: { + main: { + type: 'query', + description: 'Get details about some accounts.', + parameters: { + type: 'params', + required: ['dids'], + properties: { + dids: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['infos'], + properties: { + infos: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#accountView', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -1357,6 +1428,10 @@ export const schemaDict = { type: 'boolean', description: 'Get subjects that were taken down', }, + appealed: { + type: 'boolean', + description: 'Get subjects in unresolved appealed status', + }, limit: { type: 'integer', minimum: 1, @@ -1467,6 +1542,11 @@ export const schemaDict = { type: 'string', format: 'did', }, + comment: { + type: 'string', + description: + "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers", + }, }, }, }, @@ -1937,6 +2017,7 @@ export const schemaDict = { 'com.atproto.moderation.defs#reasonSexual', 'com.atproto.moderation.defs#reasonRude', 'com.atproto.moderation.defs#reasonOther', + 'com.atproto.moderation.defs#reasonAppeal', ], }, reasonSpam: { @@ -1964,6 +2045,10 @@ export const schemaDict = { type: 'token', description: 'Other: reports not falling under another report category', }, + reasonAppeal: { + type: 'token', + description: 'Appeal: appeal a previously taken moderation action', + }, }, }, ComAtprotoRepoApplyWrites: { @@ -4002,6 +4087,135 @@ 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: '*/*', + }, + }, + }, + }, + ComAtprotoTempTransferAccount: { + lexicon: 1, + id: 'com.atproto.temp.transferAccount', + defs: { + main: { + type: 'procedure', + description: 'Transfer an account.', + 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', @@ -5443,6 +5657,7 @@ export const schemaDict = { 'posts_with_replies', 'posts_no_replies', 'posts_with_media', + 'posts_and_author_threads', ], default: 'posts_with_replies', }, @@ -7195,6 +7410,10 @@ export const schemaDict = { ref: 'lex:app.bsky.notification.listNotifications#notification', }, }, + seenAt: { + type: 'string', + format: 'datetime', + }, }, }, }, @@ -7701,6 +7920,7 @@ export const ids = { ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent', ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites', ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo', + ComAtprotoAdminGetAccountInfos: 'com.atproto.admin.getAccountInfos', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent', ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', @@ -7770,6 +7990,9 @@ export const ids = { ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', + ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', + ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', + 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/app/bsky/feed/getAuthorFeed.ts b/packages/api/src/client/types/app/bsky/feed/getAuthorFeed.ts index 3f3abc9933f..a070dad6ff7 100644 --- a/packages/api/src/client/types/app/bsky/feed/getAuthorFeed.ts +++ b/packages/api/src/client/types/app/bsky/feed/getAuthorFeed.ts @@ -16,6 +16,7 @@ export interface QueryParams { | 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' + | 'posts_and_author_threads' | (string & {}) } diff --git a/packages/api/src/client/types/app/bsky/notification/listNotifications.ts b/packages/api/src/client/types/app/bsky/notification/listNotifications.ts index 149ad58e475..a4621f4b208 100644 --- a/packages/api/src/client/types/app/bsky/notification/listNotifications.ts +++ b/packages/api/src/client/types/app/bsky/notification/listNotifications.ts @@ -20,6 +20,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string notifications: Notification[] + seenAt?: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index cd55a41b97c..aea27e86905 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -76,6 +76,7 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventResolveAppeal | { $type: string; [k: string]: unknown } subject: | RepoView @@ -147,7 +148,11 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** Timestamp referencing when the author of the subject appealed a moderation action */ + lastAppealedAt?: string takendown?: boolean + /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -250,6 +255,7 @@ export interface AccountView { did: string handle: string email?: string + relatedRecords?: {}[] indexedAt: string invitedBy?: ComAtprotoServerDefs.InviteCode invites?: ComAtprotoServerDefs.InviteCode[] @@ -538,6 +544,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v) } +/** Resolve appeal on a subject */ +export interface ModEventResolveAppeal { + /** Describe resolution. */ + comment?: string + [k: string]: unknown +} + +export function isModEventResolveAppeal( + v: unknown, +): v is ModEventResolveAppeal { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#modEventResolveAppeal' + ) +} + +export function validateModEventResolveAppeal(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v) +} + /** Add a comment to a subject */ export interface ModEventComment { comment: string @@ -674,6 +701,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** Additional comment about the outgoing comm. */ + comment?: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/admin/getAccountInfos.ts b/packages/api/src/client/types/com/atproto/admin/getAccountInfos.ts new file mode 100644 index 00000000000..353f3150854 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/getAccountInfos.ts @@ -0,0 +1,36 @@ +/** + * 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' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams { + dids: string[] +} + +export type InputSchema = undefined + +export interface OutputSchema { + infos: ComAtprotoAdminDefs.AccountView[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts index 80eb17d8cb3..0039016a353 100644 --- a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts @@ -31,6 +31,8 @@ export interface QueryParams { sortDirection?: 'asc' | 'desc' /** Get subjects that were taken down */ takendown?: boolean + /** Get subjects in unresolved appealed status */ + appealed?: boolean limit?: number cursor?: string } diff --git a/packages/api/src/client/types/com/atproto/admin/sendEmail.ts b/packages/api/src/client/types/com/atproto/admin/sendEmail.ts index 3357ef3f762..4768fc75ca5 100644 --- a/packages/api/src/client/types/com/atproto/admin/sendEmail.ts +++ b/packages/api/src/client/types/com/atproto/admin/sendEmail.ts @@ -14,6 +14,8 @@ export interface InputSchema { content: string subject?: string senderDid: string + /** Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers */ + comment?: string [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/moderation/defs.ts b/packages/api/src/client/types/com/atproto/moderation/defs.ts index b6463993614..802cd2bc996 100644 --- a/packages/api/src/client/types/com/atproto/moderation/defs.ts +++ b/packages/api/src/client/types/com/atproto/moderation/defs.ts @@ -13,6 +13,7 @@ export type ReasonType = | 'com.atproto.moderation.defs#reasonSexual' | 'com.atproto.moderation.defs#reasonRude' | 'com.atproto.moderation.defs#reasonOther' + | 'com.atproto.moderation.defs#reasonAppeal' | (string & {}) /** Spam: frequent unwanted promotion, replies, mentions */ @@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual' export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude' /** Other: reports not falling under another report category */ export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther' +/** Appeal: appeal a previously taken moderation action */ +export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal' diff --git a/packages/api/src/client/types/com/atproto/temp/importRepo.ts b/packages/api/src/client/types/com/atproto/temp/importRepo.ts new file mode 100644 index 00000000000..6f9f99f2b9d --- /dev/null +++ b/packages/api/src/client/types/com/atproto/temp/importRepo.ts @@ -0,0 +1,33 @@ +/** + * 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 new file mode 100644 index 00000000000..32165bc8014 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/temp/pushBlob.ts @@ -0,0 +1,32 @@ +/** + * 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 new file mode 100644 index 00000000000..7ae16c01290 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/temp/transferAccount.ts @@ -0,0 +1,92 @@ +/** + * 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/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 0aaebd14421..386f77196e7 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -15,6 +15,7 @@ import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/di import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' +import * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent' import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' @@ -74,6 +75,9 @@ import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOf import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' 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 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' @@ -132,6 +136,7 @@ export const COM_ATPROTO_MODERATION = { DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual', DefsReasonRude: 'com.atproto.moderation.defs#reasonRude', DefsReasonOther: 'com.atproto.moderation.defs#reasonOther', + DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal', } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', @@ -261,6 +266,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + getAccountInfos( + cfg: ConfigOf< + AV, + ComAtprotoAdminGetAccountInfos.Handler>, + ComAtprotoAdminGetAccountInfos.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.getAccountInfos' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getInviteCodes( cfg: ConfigOf< AV, @@ -965,6 +981,39 @@ export class TempNS { const nsid = 'com.atproto.temp.fetchLabels' // @ts-ignore 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) + } + + 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 { @@ -1561,11 +1610,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 3357674dc26..258d297c69e 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -102,6 +102,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#modEventAcknowledge', 'lex:com.atproto.admin.defs#modEventEscalate', 'lex:com.atproto.admin.defs#modEventMute', + 'lex:com.atproto.admin.defs#modEventResolveAppeal', ], }, subject: { @@ -237,9 +238,20 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + lastAppealedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the author of the subject appealed a moderation action', + }, takendown: { type: 'boolean', }, + appealed: { + type: 'boolean', + description: + 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -424,6 +436,12 @@ export const schemaDict = { email: { type: 'string', }, + relatedRecords: { + type: 'array', + items: { + type: 'unknown', + }, + }, indexedAt: { type: 'string', format: 'datetime', @@ -717,6 +735,16 @@ export const schemaDict = { }, }, }, + modEventResolveAppeal: { + type: 'object', + description: 'Resolve appeal on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe resolution.', + }, + }, + }, modEventComment: { type: 'object', description: 'Add a comment to a subject', @@ -816,6 +844,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + comment: { + type: 'string', + description: 'Additional comment about the outgoing comm.', + }, }, }, }, @@ -1020,6 +1052,45 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetAccountInfos: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfos', + defs: { + main: { + type: 'query', + description: 'Get details about some accounts.', + parameters: { + type: 'params', + required: ['dids'], + properties: { + dids: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['infos'], + properties: { + infos: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#accountView', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -1357,6 +1428,10 @@ export const schemaDict = { type: 'boolean', description: 'Get subjects that were taken down', }, + appealed: { + type: 'boolean', + description: 'Get subjects in unresolved appealed status', + }, limit: { type: 'integer', minimum: 1, @@ -1467,6 +1542,11 @@ export const schemaDict = { type: 'string', format: 'did', }, + comment: { + type: 'string', + description: + "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers", + }, }, }, }, @@ -1937,6 +2017,7 @@ export const schemaDict = { 'com.atproto.moderation.defs#reasonSexual', 'com.atproto.moderation.defs#reasonRude', 'com.atproto.moderation.defs#reasonOther', + 'com.atproto.moderation.defs#reasonAppeal', ], }, reasonSpam: { @@ -1964,6 +2045,10 @@ export const schemaDict = { type: 'token', description: 'Other: reports not falling under another report category', }, + reasonAppeal: { + type: 'token', + description: 'Appeal: appeal a previously taken moderation action', + }, }, }, ComAtprotoRepoApplyWrites: { @@ -4002,6 +4087,135 @@ 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: '*/*', + }, + }, + }, + }, + ComAtprotoTempTransferAccount: { + lexicon: 1, + id: 'com.atproto.temp.transferAccount', + defs: { + main: { + type: 'procedure', + description: 'Transfer an account.', + 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', @@ -5443,6 +5657,7 @@ export const schemaDict = { 'posts_with_replies', 'posts_no_replies', 'posts_with_media', + 'posts_and_author_threads', ], default: 'posts_with_replies', }, @@ -7195,6 +7410,10 @@ export const schemaDict = { ref: 'lex:app.bsky.notification.listNotifications#notification', }, }, + seenAt: { + type: 'string', + format: 'datetime', + }, }, }, }, @@ -7701,6 +7920,7 @@ export const ids = { ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent', ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites', ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo', + ComAtprotoAdminGetAccountInfos: 'com.atproto.admin.getAccountInfos', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent', ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', @@ -7770,6 +7990,9 @@ export const ids = { ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', + ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', + ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', + 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/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts index cd66ef5c392..25f51f6fe5f 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts @@ -17,6 +17,7 @@ export interface QueryParams { | 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' + | 'posts_and_author_threads' | (string & {}) } diff --git a/packages/bsky/src/lexicon/types/app/bsky/notification/listNotifications.ts b/packages/bsky/src/lexicon/types/app/bsky/notification/listNotifications.ts index 156ba349ec4..b50d6e8282e 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/notification/listNotifications.ts @@ -21,6 +21,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string notifications: Notification[] + seenAt?: string [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index 27f080cbe31..8236f848fa0 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -76,6 +76,7 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventResolveAppeal | { $type: string; [k: string]: unknown } subject: | RepoView @@ -147,7 +148,11 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** Timestamp referencing when the author of the subject appealed a moderation action */ + lastAppealedAt?: string takendown?: boolean + /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -250,6 +255,7 @@ export interface AccountView { did: string handle: string email?: string + relatedRecords?: {}[] indexedAt: string invitedBy?: ComAtprotoServerDefs.InviteCode invites?: ComAtprotoServerDefs.InviteCode[] @@ -538,6 +544,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v) } +/** Resolve appeal on a subject */ +export interface ModEventResolveAppeal { + /** Describe resolution. */ + comment?: string + [k: string]: unknown +} + +export function isModEventResolveAppeal( + v: unknown, +): v is ModEventResolveAppeal { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#modEventResolveAppeal' + ) +} + +export function validateModEventResolveAppeal(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v) +} + /** Add a comment to a subject */ export interface ModEventComment { comment: string @@ -674,6 +701,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** Additional comment about the outgoing comm. */ + comment?: string [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfos.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfos.ts new file mode 100644 index 00000000000..46d917293a8 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfos.ts @@ -0,0 +1,46 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams { + dids: string[] +} + +export type InputSchema = undefined + +export interface OutputSchema { + infos: ComAtprotoAdminDefs.AccountView[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts index d4e55aff386..6e1aea1f679 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts @@ -32,6 +32,8 @@ export interface QueryParams { sortDirection: 'asc' | 'desc' /** Get subjects that were taken down */ takendown?: boolean + /** Get subjects in unresolved appealed status */ + appealed?: boolean limit: number cursor?: string } diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/sendEmail.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/sendEmail.ts index 91b53d9be81..f94cfb3a083 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/sendEmail.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/sendEmail.ts @@ -15,6 +15,8 @@ export interface InputSchema { content: string subject?: string senderDid: string + /** Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers */ + comment?: string [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts index 81697226189..08e555c2422 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts @@ -13,6 +13,7 @@ export type ReasonType = | 'com.atproto.moderation.defs#reasonSexual' | 'com.atproto.moderation.defs#reasonRude' | 'com.atproto.moderation.defs#reasonOther' + | 'com.atproto.moderation.defs#reasonAppeal' | (string & {}) /** Spam: frequent unwanted promotion, replies, mentions */ @@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual' export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude' /** Other: reports not falling under another report category */ export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther' +/** Appeal: appeal a previously taken moderation action */ +export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal' diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/importRepo.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/importRepo.ts new file mode 100644 index 00000000000..d88361d9856 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/importRepo.ts @@ -0,0 +1,45 @@ +/** + * 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 } 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 +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 new file mode 100644 index 00000000000..97e890dbb14 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/pushBlob.ts @@ -0,0 +1,39 @@ +/** + * 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 } 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 new file mode 100644 index 00000000000..86c1d750e07 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/transferAccount.ts @@ -0,0 +1,62 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + 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 +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/dev-env/src/network.ts b/packages/dev-env/src/network.ts index 9fc6bbfa74e..2a20811147a 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -51,7 +51,8 @@ export class TestNetwork extends TestNetworkNoAppView { didPlcUrl: plc.url, bskyAppViewUrl: bsky.url, bskyAppViewDid: bsky.ctx.cfg.serverDid, - bskyAppViewModeration: true, + modServiceUrl: bsky.url, // @TODO using appview, but will be separate service + modServiceDid: bsky.ctx.cfg.serverDid, ...params.pds, }) diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index 9d4bb26982e..a4b67166e07 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -44,6 +44,8 @@ export class TestPds { bskyAppViewUrl: 'https://appview.invalid', bskyAppViewDid: 'did:example:invalid', bskyAppViewCdnUrlPattern: 'http://cdn.appview.com/%s/%s/%s', + modServiceUrl: 'https://appview.invalid', + modServiceDid: 'did:example:invalid', repoSigningKeyK256PrivateKeyHex: repoSigningPriv, plcRotationKeyK256PrivateKeyHex: plcRotationPriv, inviteRequired: false, diff --git a/packages/pds/src/api/app/bsky/actor/getProfile.ts b/packages/pds/src/api/app/bsky/actor/getProfile.ts index 5b584f907af..fe6424072f2 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfile.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfile.ts @@ -37,7 +37,7 @@ export default function (server: Server, ctx: AppContext) { agent.api.app.bsky.actor.getProfile( params, requester - ? await ctx.serviceAuthHeaders(requester) + ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), ), ) diff --git a/packages/pds/src/api/app/bsky/actor/getProfiles.ts b/packages/pds/src/api/app/bsky/actor/getProfiles.ts index cc32abd7611..8e52254573a 100644 --- a/packages/pds/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/pds/src/api/app/bsky/actor/getProfiles.ts @@ -33,7 +33,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.actor.getProfiles( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) const hasSelf = res.data.profiles.some((prof) => prof.did === requester) diff --git a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts index f3015f375a7..2186b83e4a5 100644 --- a/packages/pds/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/pds/src/api/app/bsky/actor/getSuggestions.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.actor.getSuggestions( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/actor/searchActors.ts b/packages/pds/src/api/app/bsky/actor/searchActors.ts index 3546149802f..c226a884ef1 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActors.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActors.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.actor.searchActors( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts index 7dce6b4ba50..56bade12612 100644 --- a/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/pds/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.actor.searchActorsTypeahead( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts index 5e7f38ce533..63ec0861b2d 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorFeeds.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getActorFeeds( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts index 95c9ad64d46..a4422135f85 100644 --- a/packages/pds/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getActorLikes.ts @@ -38,7 +38,7 @@ export default function (server: Server, ctx: AppContext) { agent.api.app.bsky.feed.getActorLikes( params, requester - ? await ctx.serviceAuthHeaders(requester) + ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), ), ) diff --git a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts index a509079b4cb..2ee5bb53fe0 100644 --- a/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getAuthorFeed.ts @@ -38,7 +38,7 @@ export default function (server: Server, ctx: AppContext) { agent.api.app.bsky.feed.getAuthorFeed( params, requester - ? await ctx.serviceAuthHeaders(requester) + ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), ), ) diff --git a/packages/pds/src/api/app/bsky/feed/getFeed.ts b/packages/pds/src/api/app/bsky/feed/getFeed.ts index 6e1a943621b..85f9aa4bdc5 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeed.ts @@ -31,7 +31,7 @@ export default function (server: Server, ctx: AppContext) { const { data: feed } = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getFeedGenerator( { feed: params.feed }, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) const res = await proxyAppView(ctx, async (agent) => diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts index 5569d55db95..ebd9d8fef7f 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerator.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getFeedGenerator( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts index c0913470a4e..0fc4ef1e978 100644 --- a/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/feed/getFeedGenerators.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getFeedGenerators( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getLikes.ts b/packages/pds/src/api/app/bsky/feed/getLikes.ts index 173c469bf8e..410fd16ed0f 100644 --- a/packages/pds/src/api/app/bsky/feed/getLikes.ts +++ b/packages/pds/src/api/app/bsky/feed/getLikes.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getLikes( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getListFeed.ts b/packages/pds/src/api/app/bsky/feed/getListFeed.ts index 086cfff20dc..773fd810394 100644 --- a/packages/pds/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/pds/src/api/app/bsky/feed/getListFeed.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getListFeed( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getPostThread.ts b/packages/pds/src/api/app/bsky/feed/getPostThread.ts index 354e4b168aa..1bc526d9c10 100644 --- a/packages/pds/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/pds/src/api/app/bsky/feed/getPostThread.ts @@ -68,7 +68,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getPostThread( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) @@ -227,7 +227,7 @@ const readAfterWriteNotFound = async ( const parentsRes = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getPostThread( { uri: highestParent, parentHeight: params.parentHeight, depth: 0 }, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) thread.parent = parentsRes.data.thread diff --git a/packages/pds/src/api/app/bsky/feed/getPosts.ts b/packages/pds/src/api/app/bsky/feed/getPosts.ts index aa69d1c1208..7d0002791e8 100644 --- a/packages/pds/src/api/app/bsky/feed/getPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/getPosts.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getPosts( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts index 8db332c508c..01a0ffb2e95 100644 --- a/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/pds/src/api/app/bsky/feed/getRepostedBy.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getRepostedBy( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts index 9ae3ae47227..7befe3c1f82 100644 --- a/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/pds/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getSuggestedFeeds( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/feed/getTimeline.ts b/packages/pds/src/api/app/bsky/feed/getTimeline.ts index 214ec168129..d86b9dc8fd3 100644 --- a/packages/pds/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/pds/src/api/app/bsky/feed/getTimeline.ts @@ -33,7 +33,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.getTimeline( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return await handleReadAfterWrite(ctx, requester, res, getTimelineMunge) diff --git a/packages/pds/src/api/app/bsky/feed/searchPosts.ts b/packages/pds/src/api/app/bsky/feed/searchPosts.ts index 28811ee8334..01b97b04d75 100644 --- a/packages/pds/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/pds/src/api/app/bsky/feed/searchPosts.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.feed.searchPosts( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/getBlocks.ts b/packages/pds/src/api/app/bsky/graph/getBlocks.ts index 0b7bedbc246..fe2bcd4df49 100644 --- a/packages/pds/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getBlocks.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.getBlocks( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/getFollowers.ts b/packages/pds/src/api/app/bsky/graph/getFollowers.ts index e2c763882ec..f6e76c21b8a 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollowers.ts @@ -34,7 +34,7 @@ export default function (server: Server, ctx: AppContext) { agent.api.app.bsky.graph.getFollowers( params, requester - ? await ctx.serviceAuthHeaders(requester) + ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), ), ) diff --git a/packages/pds/src/api/app/bsky/graph/getFollows.ts b/packages/pds/src/api/app/bsky/graph/getFollows.ts index f0aa7655e49..471952e1b93 100644 --- a/packages/pds/src/api/app/bsky/graph/getFollows.ts +++ b/packages/pds/src/api/app/bsky/graph/getFollows.ts @@ -34,7 +34,7 @@ export default function (server: Server, ctx: AppContext) { agent.api.app.bsky.graph.getFollows( params, requester - ? await ctx.serviceAuthHeaders(requester) + ? await ctx.appviewAuthHeaders(requester) : authPassthru(req), ), ) diff --git a/packages/pds/src/api/app/bsky/graph/getList.ts b/packages/pds/src/api/app/bsky/graph/getList.ts index 3b826859351..5265b1f2256 100644 --- a/packages/pds/src/api/app/bsky/graph/getList.ts +++ b/packages/pds/src/api/app/bsky/graph/getList.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.getList( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts index 2b0425f3b1f..6f5926cea0d 100644 --- a/packages/pds/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/pds/src/api/app/bsky/graph/getListBlocks.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.getListBlocks( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/getListMutes.ts b/packages/pds/src/api/app/bsky/graph/getListMutes.ts index f62e27771d3..c1a8c7b56bb 100644 --- a/packages/pds/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getListMutes.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.getListMutes( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/getLists.ts b/packages/pds/src/api/app/bsky/graph/getLists.ts index b2a1b401186..95aba1a928b 100644 --- a/packages/pds/src/api/app/bsky/graph/getLists.ts +++ b/packages/pds/src/api/app/bsky/graph/getLists.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.getLists( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/getMutes.ts b/packages/pds/src/api/app/bsky/graph/getMutes.ts index fba3afe09c1..2d27a18d640 100644 --- a/packages/pds/src/api/app/bsky/graph/getMutes.ts +++ b/packages/pds/src/api/app/bsky/graph/getMutes.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.getMutes( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index 1a8993bf90e..e26b6d490ec 100644 --- a/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/pds/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -31,7 +31,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.getSuggestedFollowsByActor( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/graph/muteActor.ts b/packages/pds/src/api/app/bsky/graph/muteActor.ts index e0059b58e38..a94b8058ca7 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActor.ts @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.muteActor(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester)), encoding: 'application/json', }), ) diff --git a/packages/pds/src/api/app/bsky/graph/muteActorList.ts b/packages/pds/src/api/app/bsky/graph/muteActorList.ts index 8c881e4f8a9..6c290a75b49 100644 --- a/packages/pds/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/muteActorList.ts @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.muteActorList(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester)), encoding: 'application/json', }), ) diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts index 427a47219f5..415fe89598f 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActor.ts @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.unmuteActor(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester)), encoding: 'application/json', }), ) diff --git a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts index a6a770cf05e..7d0ca711e42 100644 --- a/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/pds/src/api/app/bsky/graph/unmuteActorList.ts @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did await proxyAppView(ctx, async (agent) => agent.api.app.bsky.graph.unmuteActorList(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester)), encoding: 'application/json', }), ) diff --git a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts index 96bab0f01f9..c0ac6219502 100644 --- a/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/pds/src/api/app/bsky/notification/getUnreadCount.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.notification.getUnreadCount( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/notification/listNotifications.ts b/packages/pds/src/api/app/bsky/notification/listNotifications.ts index 2b0bdf3266e..d3cdcc03277 100644 --- a/packages/pds/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/pds/src/api/app/bsky/notification/listNotifications.ts @@ -31,7 +31,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.notification.listNotifications( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/app/bsky/notification/updateSeen.ts b/packages/pds/src/api/app/bsky/notification/updateSeen.ts index 49f93096ab8..89c3aa59c24 100644 --- a/packages/pds/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/pds/src/api/app/bsky/notification/updateSeen.ts @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { const requester = auth.credentials.did await proxyAppView(ctx, async (agent) => agent.api.app.bsky.notification.updateSeen(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), + ...(await ctx.appviewAuthHeaders(requester)), encoding: 'application/json', }), ) diff --git a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index f6a63c5d92d..db77a8767d3 100644 --- a/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/pds/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -32,7 +32,7 @@ export default function (server: Server, ctx: AppContext) { const res = await proxyAppView(ctx, async (agent) => agent.api.app.bsky.unspecced.getPopularFeedGenerators( params, - await ctx.serviceAuthHeaders(requester), + await ctx.appviewAuthHeaders(requester), ), ) return { diff --git a/packages/pds/src/api/com/atproto/admin/emitModerationEvent.ts b/packages/pds/src/api/com/atproto/admin/emitModerationEvent.ts index 82f40ed047d..d9b4f5c7b27 100644 --- a/packages/pds/src/api/com/atproto/admin/emitModerationEvent.ts +++ b/packages/pds/src/api/com/atproto/admin/emitModerationEvent.ts @@ -7,7 +7,7 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.role, handler: async ({ req, input }) => { const { data: result } = - await ctx.appViewAgent.com.atproto.admin.emitModerationEvent( + await ctx.moderationAgent.com.atproto.admin.emitModerationEvent( input.body, authPassthru(req, true), ) diff --git a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts index cf751d08df4..5284eca9748 100644 --- a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts +++ b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts @@ -1,14 +1,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' -import { ensureValidAdminAud } from '../../../../auth-verifier' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getAccountInfo({ auth: ctx.authVerifier.roleOrAdminService, - handler: async ({ params, auth }) => { - // any admin role auth can get account info, but verify aud on service jwt - ensureValidAdminAud(auth, params.did) + handler: async ({ params }) => { const view = await ctx.services.account(ctx.db).adminView(params.did) if (!view) { throw new InvalidRequestError('Account not found', 'NotFound') diff --git a/packages/pds/src/api/com/atproto/admin/getModerationEvent.ts b/packages/pds/src/api/com/atproto/admin/getModerationEvent.ts index 3ac6e0f72be..d368c3bfd72 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationEvent.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationEvent.ts @@ -7,7 +7,7 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.role, handler: async ({ req, params }) => { const { data } = - await ctx.appViewAgent.com.atproto.admin.getModerationEvent( + await ctx.moderationAgent.com.atproto.admin.getModerationEvent( params, authPassthru(req), ) diff --git a/packages/pds/src/api/com/atproto/admin/getRecord.ts b/packages/pds/src/api/com/atproto/admin/getRecord.ts index 9b6860ca1f2..90575354028 100644 --- a/packages/pds/src/api/com/atproto/admin/getRecord.ts +++ b/packages/pds/src/api/com/atproto/admin/getRecord.ts @@ -7,7 +7,7 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.role, handler: async ({ req, params }) => { const { data: recordDetailAppview } = - await ctx.appViewAgent.com.atproto.admin.getRecord( + await ctx.moderationAgent.com.atproto.admin.getRecord( params, authPassthru(req), ) diff --git a/packages/pds/src/api/com/atproto/admin/getRepo.ts b/packages/pds/src/api/com/atproto/admin/getRepo.ts index f70ddc7e0fe..85592c52b14 100644 --- a/packages/pds/src/api/com/atproto/admin/getRepo.ts +++ b/packages/pds/src/api/com/atproto/admin/getRepo.ts @@ -6,7 +6,7 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ auth: ctx.authVerifier.role, handler: async ({ req, params }) => { - const res = await ctx.appViewAgent.com.atproto.admin.getRepo( + const res = await ctx.moderationAgent.com.atproto.admin.getRepo( params, authPassthru(req), ) diff --git a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts index 468c5d707f5..3a6425d616b 100644 --- a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts @@ -4,17 +4,15 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSubjectStatus' -import { ensureValidAdminAud } from '../../../../auth-verifier' import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getSubjectStatus({ auth: ctx.authVerifier.roleOrAdminService, - handler: async ({ params, auth, req }) => { + handler: async ({ params, req }) => { const modSrvc = ctx.services.moderation(ctx.db) const accSrvc = ctx.services.account(ctx.db) const { did, uri, blob } = parseSubject(params) - ensureValidAdminAud(auth, did) const account = await accSrvc.getAccount(did, true) const proxied = await proxy(ctx, account?.pdsDid, async (agent) => { diff --git a/packages/pds/src/api/com/atproto/admin/queryModerationEvents.ts b/packages/pds/src/api/com/atproto/admin/queryModerationEvents.ts index 4ccb0ac9f6b..00e12439649 100644 --- a/packages/pds/src/api/com/atproto/admin/queryModerationEvents.ts +++ b/packages/pds/src/api/com/atproto/admin/queryModerationEvents.ts @@ -7,7 +7,7 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.role, handler: async ({ req, params }) => { const { data: result } = - await ctx.appViewAgent.com.atproto.admin.queryModerationEvents( + await ctx.moderationAgent.com.atproto.admin.queryModerationEvents( params, authPassthru(req), ) diff --git a/packages/pds/src/api/com/atproto/admin/queryModerationStatuses.ts b/packages/pds/src/api/com/atproto/admin/queryModerationStatuses.ts index 4f6c85e17d2..d2b2f36a1fe 100644 --- a/packages/pds/src/api/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/pds/src/api/com/atproto/admin/queryModerationStatuses.ts @@ -7,7 +7,7 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.role, handler: async ({ req, params }) => { const { data } = - await ctx.appViewAgent.com.atproto.admin.queryModerationStatuses( + await ctx.moderationAgent.com.atproto.admin.queryModerationStatuses( params, authPassthru(req), ) diff --git a/packages/pds/src/api/com/atproto/admin/searchRepos.ts b/packages/pds/src/api/com/atproto/admin/searchRepos.ts index ae17dde36f7..0dcffe2730e 100644 --- a/packages/pds/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/api/com/atproto/admin/searchRepos.ts @@ -9,7 +9,7 @@ export default function (server: Server, ctx: AppContext) { // @TODO merge invite details to this list view. could also add // support for invitedBy param, which is not supported by appview. const { data: result } = - await ctx.appViewAgent.com.atproto.admin.searchRepos( + await ctx.moderationAgent.com.atproto.admin.searchRepos( params, authPassthru(req), ) diff --git a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts index 93774732db6..f11e238fc62 100644 --- a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -9,7 +9,6 @@ import { } from '../../../../lexicon/types/com/atproto/admin/defs' import { InputSchema } from '../../../../lexicon/types/com/atproto/admin/updateSubjectStatus' import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' -import { ensureValidAdminAud } from '../../../../auth-verifier' import { authPassthru, proxy, resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { @@ -25,7 +24,6 @@ export default function (server: Server, ctx: AppContext) { const { subject, takedown } = input.body const { did, uri, blob } = parseSubject(subject) - ensureValidAdminAud(auth, did) const modSrvc = ctx.services.moderation(ctx.db) const authSrvc = ctx.services.auth(ctx.db) diff --git a/packages/pds/src/api/com/atproto/moderation/createReport.ts b/packages/pds/src/api/com/atproto/moderation/createReport.ts index 79abd286a71..d38009cb8e5 100644 --- a/packages/pds/src/api/com/atproto/moderation/createReport.ts +++ b/packages/pds/src/api/com/atproto/moderation/createReport.ts @@ -4,7 +4,6 @@ import { authPassthru, ensureThisPds, proxy, - proxyAppView, resultPassthru, } from '../../../proxy' @@ -30,12 +29,14 @@ export default function (server: Server, ctx: AppContext) { ensureThisPds(ctx, auth.credentials.pdsDid) const requester = auth.credentials.did - const { data: result } = await proxyAppView(ctx, async (agent) => - agent.com.atproto.moderation.createReport(input.body, { - ...(await ctx.serviceAuthHeaders(requester)), - encoding: 'application/json', - }), - ) + const { data: result } = + await ctx.moderationAgent.com.atproto.moderation.createReport( + input.body, + { + ...(await ctx.moderationAuthHeaders(requester)), + encoding: 'application/json', + }, + ) return { encoding: 'application/json', body: result, diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index a091ebb446b..cba9094fb34 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -99,7 +99,8 @@ export type AuthVerifierOpts = { adminPass: string moderatorPass: string triagePass: string - adminServiceDid: string + pdsServiceDid: string + modServiceDid: string } export class AuthVerifier { @@ -108,7 +109,8 @@ export class AuthVerifier { private _adminPass: string private _moderatorPass: string private _triagePass: string - private _adminServiceDid: string + private _pdsServiceDid: string + private _modServiceDid: string constructor( public db: Database, @@ -120,7 +122,8 @@ export class AuthVerifier { this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass this._triagePass = opts.triagePass - this._adminServiceDid = opts.adminServiceDid + this._pdsServiceDid = opts.pdsServiceDid + this._modServiceDid = opts.modServiceDid } // verifiers (arrow fns to preserve scope) @@ -235,9 +238,9 @@ export class AuthVerifier { } const payload = await verifyServiceJwt( jwtStr, - null, + this._pdsServiceDid, async (did, forceRefresh) => { - if (did !== this._adminServiceDid) { + if (did !== this._modServiceDid) { throw new AuthRequiredError( 'Untrusted issuer for admin actions', 'UntrustedIss', @@ -454,21 +457,6 @@ export const parseBasicAuth = ( return { username, password } } -export const ensureValidAdminAud = ( - auth: RoleOutput | AdminServiceOutput, - subjectDid: string, -) => { - if ( - auth.credentials.type === 'service' && - auth.credentials.aud !== subjectDid - ) { - throw new AuthRequiredError( - 'jwt audience does not match account did', - 'BadJwtAudience', - ) - } -} - const authScopes = new Set(Object.values(AuthScope)) const isAuthScope = (val: unknown): val is AuthScope => { return authScopes.has(val as any) diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 70271612e7a..d8ec5a031a2 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -1,5 +1,6 @@ import os from 'node:os' import path from 'node:path' +import assert from 'node:assert' import { DAY, HOUR, SECOND } from '@atproto/common' import { ServerEnvironment } from './env' @@ -162,18 +163,21 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { sequencerLeaderLockId: env.sequencerLeaderLockId ?? 1100, } - if (!env.bskyAppViewUrl) { - throw new Error('Must configure PDS_BSKY_APP_VIEW_URL') - } else if (!env.bskyAppViewDid) { - throw new Error('Must configure PDS_BSKY_APP_VIEW_DID') - } + assert(env.bskyAppViewUrl) + assert(env.bskyAppViewDid) const bskyAppViewCfg: ServerConfig['bskyAppView'] = { url: env.bskyAppViewUrl, did: env.bskyAppViewDid, - proxyModeration: env.bskyAppViewModeration ?? false, cdnUrlPattern: env.bskyAppViewCdnUrlPattern, } + assert(env.modServiceUrl) + assert(env.modServiceDid) + const modServiceCfg: ServerConfig['modService'] = { + url: env.modServiceUrl, + did: env.modServiceDid, + } + const redisCfg: ServerConfig['redis'] = env.redisScratchAddress ? { address: env.redisScratchAddress, @@ -204,6 +208,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { moderationEmail: moderationEmailCfg, subscription: subscriptionCfg, bskyAppView: bskyAppViewCfg, + modService: modServiceCfg, redis: redisCfg, rateLimits: rateLimitsCfg, crawlers: crawlersCfg, @@ -220,6 +225,7 @@ export type ServerConfig = { moderationEmail: EmailConfig | null subscription: SubscriptionConfig bskyAppView: BksyAppViewConfig + modService: ModServiceConfig redis: RedisScratchConfig | null rateLimits: RateLimitsConfig crawlers: string[] @@ -323,6 +329,10 @@ export type RateLimitsConfig = export type BksyAppViewConfig = { url: string did: string - proxyModeration: boolean cdnUrlPattern?: string } + +export type ModServiceConfig = { + url: string + did: string +} diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index ca4096c1b0c..0645e8b47f3 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -64,9 +64,12 @@ export const readEnv = (): ServerEnvironment => { // appview bskyAppViewUrl: envStr('PDS_BSKY_APP_VIEW_URL'), bskyAppViewDid: envStr('PDS_BSKY_APP_VIEW_DID'), - bskyAppViewModeration: envBool('PDS_BSKY_APP_VIEW_MODERATION'), bskyAppViewCdnUrlPattern: envStr('PDS_BSKY_APP_VIEW_CDN_URL_PATTERN'), + // mod service + modServiceUrl: envStr('PDS_MOD_SERVICE_URL'), + modServiceDid: envStr('PDS_MOD_SERVICE_DID'), + // rate limits rateLimitsEnabled: envBool('PDS_RATE_LIMITS_ENABLED'), rateLimitBypassKey: envStr('PDS_RATE_LIMIT_BYPASS_KEY'), @@ -168,9 +171,12 @@ export type ServerEnvironment = { // appview bskyAppViewUrl?: string bskyAppViewDid?: string - bskyAppViewModeration?: boolean bskyAppViewCdnUrlPattern?: string + // mod service + modServiceUrl?: string + modServiceDid?: string + // rate limits rateLimitsEnabled?: boolean rateLimitBypassKey?: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 84fa8183c9f..bc2b1bd3a4b 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -38,6 +38,7 @@ export type AppContextOptions = { redisScratch?: Redis crawlers: Crawlers appViewAgent: AtpAgent + moderationAgent: AtpAgent authVerifier: AuthVerifier pdsAgents: PdsAgents repoSigningKey: crypto.Keypair @@ -61,6 +62,7 @@ export class AppContext { public redisScratch?: Redis public crawlers: Crawlers public appViewAgent: AtpAgent + public moderationAgent: AtpAgent public authVerifier: AuthVerifier public pdsAgents: PdsAgents public repoSigningKey: crypto.Keypair @@ -83,6 +85,7 @@ export class AppContext { this.redisScratch = opts.redisScratch this.crawlers = opts.crawlers this.appViewAgent = opts.appViewAgent + this.moderationAgent = opts.moderationAgent this.authVerifier = opts.authVerifier this.pdsAgents = opts.pdsAgents this.repoSigningKey = opts.repoSigningKey @@ -160,6 +163,7 @@ export class AppContext { const crawlers = new Crawlers(cfg.service.hostname, cfg.crawlers) const appViewAgent = new AtpAgent({ service: cfg.bskyAppView.url }) + const moderationAgent = new AtpAgent({ service: cfg.modService.url }) const authKeys = await getAuthKeys(secrets) @@ -168,7 +172,8 @@ export class AppContext { adminPass: secrets.adminPassword, moderatorPass: secrets.moderatorPassword, triagePass: secrets.triagePassword, - adminServiceDid: cfg.bskyAppView.did, + pdsServiceDid: cfg.service.did, + modServiceDid: cfg.modService.did, }) const repoSigningKey = @@ -220,6 +225,7 @@ export class AppContext { redisScratch, crawlers, appViewAgent, + moderationAgent, authVerifier, repoSigningKey, plcRotationKey, @@ -229,11 +235,15 @@ export class AppContext { }) } - async serviceAuthHeaders(did: string, audience?: string) { - const aud = audience ?? this.cfg.bskyAppView.did - if (!aud) { - throw new Error('Could not find bsky appview did') - } + async appviewAuthHeaders(did: string) { + return this.serviceAuthHeaders(did, this.cfg.bskyAppView.did) + } + + async moderationAuthHeaders(did: string) { + return this.serviceAuthHeaders(did, this.cfg.modService.did) + } + + async serviceAuthHeaders(did: string, aud: string) { return createServiceAuthHeaders({ iss: did, aud, diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 0aaebd14421..386f77196e7 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -15,6 +15,7 @@ import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/di import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' +import * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent' import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' @@ -74,6 +75,9 @@ import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOf import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' 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 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' @@ -132,6 +136,7 @@ export const COM_ATPROTO_MODERATION = { DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual', DefsReasonRude: 'com.atproto.moderation.defs#reasonRude', DefsReasonOther: 'com.atproto.moderation.defs#reasonOther', + DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal', } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', @@ -261,6 +266,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + getAccountInfos( + cfg: ConfigOf< + AV, + ComAtprotoAdminGetAccountInfos.Handler>, + ComAtprotoAdminGetAccountInfos.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.getAccountInfos' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getInviteCodes( cfg: ConfigOf< AV, @@ -965,6 +981,39 @@ export class TempNS { const nsid = 'com.atproto.temp.fetchLabels' // @ts-ignore 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) + } + + 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 { @@ -1561,11 +1610,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 3357674dc26..258d297c69e 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -102,6 +102,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#modEventAcknowledge', 'lex:com.atproto.admin.defs#modEventEscalate', 'lex:com.atproto.admin.defs#modEventMute', + 'lex:com.atproto.admin.defs#modEventResolveAppeal', ], }, subject: { @@ -237,9 +238,20 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + lastAppealedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the author of the subject appealed a moderation action', + }, takendown: { type: 'boolean', }, + appealed: { + type: 'boolean', + description: + 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -424,6 +436,12 @@ export const schemaDict = { email: { type: 'string', }, + relatedRecords: { + type: 'array', + items: { + type: 'unknown', + }, + }, indexedAt: { type: 'string', format: 'datetime', @@ -717,6 +735,16 @@ export const schemaDict = { }, }, }, + modEventResolveAppeal: { + type: 'object', + description: 'Resolve appeal on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe resolution.', + }, + }, + }, modEventComment: { type: 'object', description: 'Add a comment to a subject', @@ -816,6 +844,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + comment: { + type: 'string', + description: 'Additional comment about the outgoing comm.', + }, }, }, }, @@ -1020,6 +1052,45 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetAccountInfos: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfos', + defs: { + main: { + type: 'query', + description: 'Get details about some accounts.', + parameters: { + type: 'params', + required: ['dids'], + properties: { + dids: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['infos'], + properties: { + infos: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#accountView', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -1357,6 +1428,10 @@ export const schemaDict = { type: 'boolean', description: 'Get subjects that were taken down', }, + appealed: { + type: 'boolean', + description: 'Get subjects in unresolved appealed status', + }, limit: { type: 'integer', minimum: 1, @@ -1467,6 +1542,11 @@ export const schemaDict = { type: 'string', format: 'did', }, + comment: { + type: 'string', + description: + "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers", + }, }, }, }, @@ -1937,6 +2017,7 @@ export const schemaDict = { 'com.atproto.moderation.defs#reasonSexual', 'com.atproto.moderation.defs#reasonRude', 'com.atproto.moderation.defs#reasonOther', + 'com.atproto.moderation.defs#reasonAppeal', ], }, reasonSpam: { @@ -1964,6 +2045,10 @@ export const schemaDict = { type: 'token', description: 'Other: reports not falling under another report category', }, + reasonAppeal: { + type: 'token', + description: 'Appeal: appeal a previously taken moderation action', + }, }, }, ComAtprotoRepoApplyWrites: { @@ -4002,6 +4087,135 @@ 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: '*/*', + }, + }, + }, + }, + ComAtprotoTempTransferAccount: { + lexicon: 1, + id: 'com.atproto.temp.transferAccount', + defs: { + main: { + type: 'procedure', + description: 'Transfer an account.', + 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', @@ -5443,6 +5657,7 @@ export const schemaDict = { 'posts_with_replies', 'posts_no_replies', 'posts_with_media', + 'posts_and_author_threads', ], default: 'posts_with_replies', }, @@ -7195,6 +7410,10 @@ export const schemaDict = { ref: 'lex:app.bsky.notification.listNotifications#notification', }, }, + seenAt: { + type: 'string', + format: 'datetime', + }, }, }, }, @@ -7701,6 +7920,7 @@ export const ids = { ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent', ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites', ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo', + ComAtprotoAdminGetAccountInfos: 'com.atproto.admin.getAccountInfos', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent', ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', @@ -7770,6 +7990,9 @@ export const ids = { ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', + ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', + ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', + 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/app/bsky/feed/getAuthorFeed.ts b/packages/pds/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts index cd66ef5c392..25f51f6fe5f 100644 --- a/packages/pds/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts +++ b/packages/pds/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts @@ -17,6 +17,7 @@ export interface QueryParams { | 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' + | 'posts_and_author_threads' | (string & {}) } diff --git a/packages/pds/src/lexicon/types/app/bsky/notification/listNotifications.ts b/packages/pds/src/lexicon/types/app/bsky/notification/listNotifications.ts index 156ba349ec4..b50d6e8282e 100644 --- a/packages/pds/src/lexicon/types/app/bsky/notification/listNotifications.ts +++ b/packages/pds/src/lexicon/types/app/bsky/notification/listNotifications.ts @@ -21,6 +21,7 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string notifications: Notification[] + seenAt?: string [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index 27f080cbe31..8236f848fa0 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -76,6 +76,7 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventResolveAppeal | { $type: string; [k: string]: unknown } subject: | RepoView @@ -147,7 +148,11 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** Timestamp referencing when the author of the subject appealed a moderation action */ + lastAppealedAt?: string takendown?: boolean + /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -250,6 +255,7 @@ export interface AccountView { did: string handle: string email?: string + relatedRecords?: {}[] indexedAt: string invitedBy?: ComAtprotoServerDefs.InviteCode invites?: ComAtprotoServerDefs.InviteCode[] @@ -538,6 +544,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v) } +/** Resolve appeal on a subject */ +export interface ModEventResolveAppeal { + /** Describe resolution. */ + comment?: string + [k: string]: unknown +} + +export function isModEventResolveAppeal( + v: unknown, +): v is ModEventResolveAppeal { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#modEventResolveAppeal' + ) +} + +export function validateModEventResolveAppeal(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v) +} + /** Add a comment to a subject */ export interface ModEventComment { comment: string @@ -674,6 +701,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** Additional comment about the outgoing comm. */ + comment?: string [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfos.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfos.ts new file mode 100644 index 00000000000..46d917293a8 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfos.ts @@ -0,0 +1,46 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams { + dids: string[] +} + +export type InputSchema = undefined + +export interface OutputSchema { + infos: ComAtprotoAdminDefs.AccountView[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts index d4e55aff386..6e1aea1f679 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts @@ -32,6 +32,8 @@ export interface QueryParams { sortDirection: 'asc' | 'desc' /** Get subjects that were taken down */ takendown?: boolean + /** Get subjects in unresolved appealed status */ + appealed?: boolean limit: number cursor?: string } diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/sendEmail.ts b/packages/pds/src/lexicon/types/com/atproto/admin/sendEmail.ts index 91b53d9be81..f94cfb3a083 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/sendEmail.ts @@ -15,6 +15,8 @@ export interface InputSchema { content: string subject?: string senderDid: string + /** Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers */ + comment?: string [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts index 81697226189..08e555c2422 100644 --- a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts @@ -13,6 +13,7 @@ export type ReasonType = | 'com.atproto.moderation.defs#reasonSexual' | 'com.atproto.moderation.defs#reasonRude' | 'com.atproto.moderation.defs#reasonOther' + | 'com.atproto.moderation.defs#reasonAppeal' | (string & {}) /** Spam: frequent unwanted promotion, replies, mentions */ @@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual' export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude' /** Other: reports not falling under another report category */ export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther' +/** Appeal: appeal a previously taken moderation action */ +export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal' diff --git a/packages/pds/src/lexicon/types/com/atproto/temp/importRepo.ts b/packages/pds/src/lexicon/types/com/atproto/temp/importRepo.ts new file mode 100644 index 00000000000..d88361d9856 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/temp/importRepo.ts @@ -0,0 +1,45 @@ +/** + * 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 } 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 +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 new file mode 100644 index 00000000000..97e890dbb14 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/temp/pushBlob.ts @@ -0,0 +1,39 @@ +/** + * 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 } 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 new file mode 100644 index 00000000000..86c1d750e07 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/temp/transferAccount.ts @@ -0,0 +1,62 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + 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 +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/tests/admin-auth.test.ts b/packages/pds/tests/admin-auth.test.ts new file mode 100644 index 00000000000..4cf7d5c26a5 --- /dev/null +++ b/packages/pds/tests/admin-auth.test.ts @@ -0,0 +1,145 @@ +import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import AtpAgent from '@atproto/api' +import { Secp256k1Keypair } from '@atproto/crypto' +import { createServiceAuthHeaders } from '@atproto/xrpc-server' +import usersSeed from './seeds/users' +import { RepoRef } from '../src/lexicon/types/com/atproto/admin/defs' + +describe('admin auth', () => { + let network: TestNetworkNoAppView + let agent: AtpAgent + let sc: SeedClient + + let repoSubject: RepoRef + + const modServiceDid = 'did:example:mod' + const altModDid = 'did:example:alt' + let modServiceKey: Secp256k1Keypair + let pdsDid: string + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'pds_admin_auth', + pds: { + modServiceDid, + }, + }) + + pdsDid = network.pds.ctx.cfg.service.did + + modServiceKey = await Secp256k1Keypair.create() + const origResolve = network.pds.ctx.idResolver.did.resolveAtprotoKey + network.pds.ctx.idResolver.did.resolveAtprotoKey = async ( + did: string, + forceRefresh?: boolean, + ) => { + if (did === modServiceDid || did === altModDid) { + return modServiceKey.did() + } + return origResolve(did, forceRefresh) + } + + agent = network.pds.getClient() + sc = network.getSeedClient() + await usersSeed(sc) + await network.processAll() + repoSubject = { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + } + }) + + afterAll(async () => { + await network.close() + }) + + it('allows service auth requests from the configured appview did', async () => { + const headers = await createServiceAuthHeaders({ + iss: modServiceDid, + aud: pdsDid, + keypair: modServiceKey, + }) + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: repoSubject.did, + }, + headers, + ) + expect(res.data.subject.did).toBe(repoSubject.did) + expect(res.data.takedown?.applied).toBe(true) + }) + + it('does not allow requests from another did', async () => { + const headers = await createServiceAuthHeaders({ + iss: altModDid, + aud: pdsDid, + keypair: modServiceKey, + }) + const attempt = agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow('Untrusted issuer for admin actions') + }) + + it('does not allow requests with a bad signature', async () => { + const badKey = await Secp256k1Keypair.create() + const headers = await createServiceAuthHeaders({ + iss: modServiceDid, + aud: pdsDid, + keypair: badKey, + }) + const attempt = agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow( + 'jwt signature does not match jwt issuer', + ) + }) + + it('does not allow requests with a bad aud', async () => { + // repo subject is bob, so we set alice as the audience + const headers = await createServiceAuthHeaders({ + iss: modServiceDid, + aud: sc.dids.alice, + keypair: modServiceKey, + }) + const attempt = agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow( + 'jwt audience does not match service did', + ) + }) +}) diff --git a/packages/pds/tests/moderation.test.ts b/packages/pds/tests/moderation.test.ts index ee68bb7aab5..50389b88cae 100644 --- a/packages/pds/tests/moderation.test.ts +++ b/packages/pds/tests/moderation.test.ts @@ -1,8 +1,6 @@ import { TestNetworkNoAppView, ImageRef, SeedClient } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { BlobNotFoundError } from '@atproto/repo' -import { Secp256k1Keypair } from '@atproto/crypto' -import { createServiceAuthHeaders } from '@atproto/xrpc-server' import basicSeed from './seeds/basic' import { RepoBlobRef, @@ -20,30 +18,11 @@ describe('moderation', () => { let blobSubject: RepoBlobRef let blobRef: ImageRef - const appviewDid = 'did:example:appview' - const altAppviewDid = 'did:example:alt' - let appviewKey: Secp256k1Keypair - beforeAll(async () => { network = await TestNetworkNoAppView.create({ dbPostgresSchema: 'moderation', - pds: { - bskyAppViewDid: appviewDid, - }, }) - appviewKey = await Secp256k1Keypair.create() - const origResolve = network.pds.ctx.idResolver.did.resolveAtprotoKey - network.pds.ctx.idResolver.did.resolveAtprotoKey = async ( - did: string, - forceRefresh?: boolean, - ) => { - if (did === appviewDid || did === altAppviewDid) { - return appviewKey.did() - } - return origResolve(did, forceRefresh) - } - agent = network.pds.getClient() sc = network.getSeedClient() await basicSeed(sc) @@ -260,98 +239,4 @@ describe('moderation', () => { expect(res.data.byteLength).toBeGreaterThan(9000) }) }) - - describe('auth', () => { - it('allows service auth requests from the configured appview did', async () => { - const headers = await createServiceAuthHeaders({ - iss: appviewDid, - aud: repoSubject.did, - keypair: appviewKey, - }) - await agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: repoSubject, - takedown: { applied: true, ref: 'test-repo' }, - }, - { - ...headers, - encoding: 'application/json', - }, - ) - - const res = await agent.api.com.atproto.admin.getSubjectStatus( - { - did: repoSubject.did, - }, - headers, - ) - expect(res.data.subject.did).toBe(repoSubject.did) - expect(res.data.takedown?.applied).toBe(true) - }) - - it('does not allow requests from another did', async () => { - const headers = await createServiceAuthHeaders({ - iss: altAppviewDid, - aud: repoSubject.did, - keypair: appviewKey, - }) - const attempt = agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: repoSubject, - takedown: { applied: true, ref: 'test-repo' }, - }, - { - ...headers, - encoding: 'application/json', - }, - ) - await expect(attempt).rejects.toThrow( - 'Untrusted issuer for admin actions', - ) - }) - - it('does not allow requests with a bad signature', async () => { - const badKey = await Secp256k1Keypair.create() - const headers = await createServiceAuthHeaders({ - iss: appviewDid, - aud: repoSubject.did, - keypair: badKey, - }) - const attempt = agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: repoSubject, - takedown: { applied: true, ref: 'test-repo' }, - }, - { - ...headers, - encoding: 'application/json', - }, - ) - await expect(attempt).rejects.toThrow( - 'jwt signature does not match jwt issuer', - ) - }) - - it('does not allow requests with a bad signature', async () => { - // repo subject is bob, so we set alice as the audience - const headers = await createServiceAuthHeaders({ - iss: appviewDid, - aud: sc.dids.alice, - keypair: appviewKey, - }) - const attempt = agent.api.com.atproto.admin.updateSubjectStatus( - { - subject: repoSubject, - takedown: { applied: true, ref: 'test-repo' }, - }, - { - ...headers, - encoding: 'application/json', - }, - ) - await expect(attempt).rejects.toThrow( - 'jwt audience does not match account did', - ) - }) - }) }) diff --git a/services/pds/index.js b/services/pds/index.js index 112d63edf90..0d9bfb171f9 100644 --- a/services/pds/index.js +++ b/services/pds/index.js @@ -18,7 +18,6 @@ const { envToSecrets, readEnv, httpLogger, - PeriodicModerationActionReversal, } = require('@atproto/pds') const pkg = require('@atproto/pds/package.json') @@ -29,16 +28,6 @@ const main = async () => { const secrets = envToSecrets(env) const pds = await PDS.create(cfg, secrets) - // If the PDS is configured to proxy moderation, this will be running on appview instead of pds. - // Also don't run this on the sequencer leader, which may not be configured regarding moderation proxying at all. - const periodicModerationActionReversal = - pds.ctx.cfg.bskyAppView.proxyModeration || - pds.ctx.cfg.sequencerLeaderEnabled - ? null - : new PeriodicModerationActionReversal(pds.ctx) - const periodicModerationActionReversalRunning = - periodicModerationActionReversal?.run() - await pds.start() httpLogger.info('pds is running') @@ -46,9 +35,6 @@ const main = async () => { process.on('SIGTERM', async () => { httpLogger.info('pds is stopping') - periodicModerationActionReversal?.destroy() - await periodicModerationActionReversalRunning - await pds.destroy() httpLogger.info('pds is stopped') From e050d23361224ac59855d3c71ac88b7353412491 Mon Sep 17 00:00:00 2001 From: dholms Date: Fri, 5 Jan 2024 18:53:42 -0600 Subject: [PATCH 041/135] fix proxy in admin.sendEmail --- packages/pds/src/api/com/atproto/admin/sendEmail.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index e4defa466a8..4adc9185099 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -31,7 +31,7 @@ export default function (server: Server, ctx: AppContext) { { content }, { subject, to: userInfo.email }, ) - await ctx.appViewAgent.api.com.atproto.admin.emitModerationEvent( + await ctx.moderationAgent.api.com.atproto.admin.emitModerationEvent( { event: { $type: 'com.atproto.admin.defs#modEventEmail', From 4c4a7d8d4c759bab6e02be88f9d0aca0875128e2 Mon Sep 17 00:00:00 2001 From: dholms Date: Fri, 5 Jan 2024 18:57:01 -0600 Subject: [PATCH 042/135] build --- .github/workflows/build-and-push-pds-aws.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index e654a50caed..8febb8ce234 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - multi-pds-auth-ozone + - multi-pds-auth env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From 03d8a3ec86ae0ceb6337edb15bd14fb644b3a7c5 Mon Sep 17 00:00:00 2001 From: dholms Date: Fri, 5 Jan 2024 18:57:36 -0600 Subject: [PATCH 043/135] dont build --- .github/workflows/build-and-push-pds-aws.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 8febb8ce234..097f782d88e 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,6 @@ on: push: branches: - main - - multi-pds-auth env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From f56ef4f74ff96e65f544e86492f697bb98997513 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 16 Jan 2024 18:02:37 -0600 Subject: [PATCH 044/135] basic twilio phone verification flow --- .../com/atproto/server/createAccount.json | 2 + .../com/atproto/server/describeServer.json | 1 + .../temp/requestPhoneVerification.json | 20 +++ packages/api/src/client/index.ts | 115 ++++++++------- packages/api/src/client/lexicons.ts | 31 ++++ .../types/com/atproto/server/createAccount.ts | 1 + .../com/atproto/server/describeServer.ts | 1 + .../atproto/temp/requestPhoneVerification.ts | 32 ++++ packages/bsky/src/lexicon/index.ts | 114 +++++++------- packages/bsky/src/lexicon/lexicons.ts | 31 ++++ .../types/com/atproto/server/createAccount.ts | 1 + .../com/atproto/server/describeServer.ts | 1 + .../atproto/temp/requestPhoneVerification.ts | 38 +++++ packages/pds/package.json | 1 + .../api/com/atproto/server/createAccount.ts | 24 +++ .../api/com/atproto/server/describeServer.ts | 2 + .../pds/src/api/com/atproto/temp/index.ts | 7 + .../atproto/temp/requestPhoneVerification.ts | 25 ++++ packages/pds/src/config/config.ts | 25 ++++ packages/pds/src/config/env.ts | 12 ++ packages/pds/src/config/secrets.ts | 2 + packages/pds/src/context.ts | 16 ++ packages/pds/src/lexicon/index.ts | 114 +++++++------- packages/pds/src/lexicon/lexicons.ts | 34 +++++ .../types/com/atproto/server/createAccount.ts | 2 + .../com/atproto/server/describeServer.ts | 1 + .../atproto/temp/requestPhoneVerification.ts | 38 +++++ packages/pds/src/twilio.ts | 30 ++++ pnpm-lock.yaml | 139 +++++++++++++++++- 29 files changed, 705 insertions(+), 155 deletions(-) create mode 100644 lexicons/com/atproto/temp/requestPhoneVerification.json create mode 100644 packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts create mode 100644 packages/pds/src/api/com/atproto/temp/index.ts create mode 100644 packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts create mode 100644 packages/pds/src/twilio.ts diff --git a/lexicons/com/atproto/server/createAccount.json b/lexicons/com/atproto/server/createAccount.json index 8d927163951..d1456e095ae 100644 --- a/lexicons/com/atproto/server/createAccount.json +++ b/lexicons/com/atproto/server/createAccount.json @@ -15,6 +15,8 @@ "handle": { "type": "string", "format": "handle" }, "did": { "type": "string", "format": "did" }, "inviteCode": { "type": "string" }, + "verificationCode": { "type": "string" }, + "verificationPhone": { "type": "string" }, "password": { "type": "string" }, "recoveryKey": { "type": "string" }, "plcOp": { "type": "unknown" } diff --git a/lexicons/com/atproto/server/describeServer.json b/lexicons/com/atproto/server/describeServer.json index b19b1504020..3c60a58ecaf 100644 --- a/lexicons/com/atproto/server/describeServer.json +++ b/lexicons/com/atproto/server/describeServer.json @@ -12,6 +12,7 @@ "required": ["availableUserDomains"], "properties": { "inviteCodeRequired": { "type": "boolean" }, + "phoneVerificationRequired": { "type": "boolean" }, "availableUserDomains": { "type": "array", "items": { "type": "string" } diff --git a/lexicons/com/atproto/temp/requestPhoneVerification.json b/lexicons/com/atproto/temp/requestPhoneVerification.json new file mode 100644 index 00000000000..56beeb81acc --- /dev/null +++ b/lexicons/com/atproto/temp/requestPhoneVerification.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.temp.requestPhoneVerification", + "defs": { + "main": { + "type": "procedure", + "description": "Request a verification code to be sent to the supplied phone number", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["phoneNumber"], + "properties": { + "phoneNumber": { "type": "string" } + } + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index fb56cd251a0..bfb1be016c9 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -80,6 +80,7 @@ import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscrib 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' @@ -220,6 +221,7 @@ export * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscrib 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' @@ -338,39 +340,39 @@ export class AtpServiceClient { export class ComNS { _service: AtpServiceClient - atproto: AtprotoNS + atproto: ComAtprotoNS constructor(service: AtpServiceClient) { this._service = service - this.atproto = new AtprotoNS(service) + this.atproto = new ComAtprotoNS(service) } } -export class AtprotoNS { +export class ComAtprotoNS { _service: AtpServiceClient - admin: AdminNS - identity: IdentityNS - label: LabelNS - moderation: ModerationNS - repo: RepoNS - server: ServerNS - sync: SyncNS - temp: TempNS + admin: ComAtprotoAdminNS + identity: ComAtprotoIdentityNS + label: ComAtprotoLabelNS + moderation: ComAtprotoModerationNS + repo: ComAtprotoRepoNS + server: ComAtprotoServerNS + sync: ComAtprotoSyncNS + temp: ComAtprotoTempNS constructor(service: AtpServiceClient) { this._service = service - this.admin = new AdminNS(service) - this.identity = new IdentityNS(service) - this.label = new LabelNS(service) - this.moderation = new ModerationNS(service) - this.repo = new RepoNS(service) - this.server = new ServerNS(service) - this.sync = new SyncNS(service) - this.temp = new TempNS(service) + this.admin = new ComAtprotoAdminNS(service) + this.identity = new ComAtprotoIdentityNS(service) + this.label = new ComAtprotoLabelNS(service) + this.moderation = new ComAtprotoModerationNS(service) + this.repo = new ComAtprotoRepoNS(service) + this.server = new ComAtprotoServerNS(service) + this.sync = new ComAtprotoSyncNS(service) + this.temp = new ComAtprotoTempNS(service) } } -export class AdminNS { +export class ComAtprotoAdminNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -592,7 +594,7 @@ export class AdminNS { } } -export class IdentityNS { +export class ComAtprotoIdentityNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -622,7 +624,7 @@ export class IdentityNS { } } -export class LabelNS { +export class ComAtprotoLabelNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -641,7 +643,7 @@ export class LabelNS { } } -export class ModerationNS { +export class ComAtprotoModerationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -660,7 +662,7 @@ export class ModerationNS { } } -export class RepoNS { +export class ComAtprotoRepoNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -756,7 +758,7 @@ export class RepoNS { } } -export class ServerNS { +export class ComAtprotoServerNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -995,7 +997,7 @@ export class ServerNS { } } -export class SyncNS { +export class ComAtprotoSyncNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1124,7 +1126,7 @@ export class SyncNS { } } -export class TempNS { +export class ComAtprotoTempNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1164,6 +1166,17 @@ export class TempNS { }) } + requestPhoneVerification( + data?: ComAtprotoTempRequestPhoneVerification.InputSchema, + opts?: ComAtprotoTempRequestPhoneVerification.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.temp.requestPhoneVerification', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoTempRequestPhoneVerification.toKnownErr(e) + }) + } + transferAccount( data?: ComAtprotoTempTransferAccount.InputSchema, opts?: ComAtprotoTempTransferAccount.CallOptions, @@ -1178,37 +1191,37 @@ export class TempNS { export class AppNS { _service: AtpServiceClient - bsky: BskyNS + bsky: AppBskyNS constructor(service: AtpServiceClient) { this._service = service - this.bsky = new BskyNS(service) + this.bsky = new AppBskyNS(service) } } -export class BskyNS { +export class AppBskyNS { _service: AtpServiceClient - actor: ActorNS - embed: EmbedNS - feed: FeedNS - graph: GraphNS - notification: NotificationNS - richtext: RichtextNS - unspecced: UnspeccedNS + actor: AppBskyActorNS + embed: AppBskyEmbedNS + feed: AppBskyFeedNS + graph: AppBskyGraphNS + notification: AppBskyNotificationNS + richtext: AppBskyRichtextNS + unspecced: AppBskyUnspeccedNS constructor(service: AtpServiceClient) { this._service = service - this.actor = new ActorNS(service) - this.embed = new EmbedNS(service) - this.feed = new FeedNS(service) - this.graph = new GraphNS(service) - this.notification = new NotificationNS(service) - this.richtext = new RichtextNS(service) - this.unspecced = new UnspeccedNS(service) + this.actor = new AppBskyActorNS(service) + this.embed = new AppBskyEmbedNS(service) + this.feed = new AppBskyFeedNS(service) + this.graph = new AppBskyGraphNS(service) + this.notification = new AppBskyNotificationNS(service) + this.richtext = new AppBskyRichtextNS(service) + this.unspecced = new AppBskyUnspeccedNS(service) } } -export class ActorNS { +export class AppBskyActorNS { _service: AtpServiceClient profile: ProfileRecord @@ -1356,7 +1369,7 @@ export class ProfileRecord { } } -export class EmbedNS { +export class AppBskyEmbedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1364,7 +1377,7 @@ export class EmbedNS { } } -export class FeedNS { +export class AppBskyFeedNS { _service: AtpServiceClient generator: GeneratorRecord like: LikeRecord @@ -1867,7 +1880,7 @@ export class ThreadgateRecord { } } -export class GraphNS { +export class AppBskyGraphNS { _service: AtpServiceClient block: BlockRecord follow: FollowRecord @@ -2342,7 +2355,7 @@ export class ListitemRecord { } } -export class NotificationNS { +export class AppBskyNotificationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2394,7 +2407,7 @@ export class NotificationNS { } } -export class RichtextNS { +export class AppBskyRichtextNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2402,7 +2415,7 @@ export class RichtextNS { } } -export class UnspeccedNS { +export class AppBskyUnspeccedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 258d297c69e..059c99be925 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2664,6 +2664,9 @@ export const schemaDict = { inviteCode: { type: 'string', }, + verificationCode: { + type: 'string', + }, password: { type: 'string', }, @@ -3068,6 +3071,9 @@ export const schemaDict = { inviteCodeRequired: { type: 'boolean', }, + phoneVerificationRequired: { + type: 'boolean', + }, availableUserDomains: { type: 'array', items: { @@ -4140,6 +4146,29 @@ export const schemaDict = { }, }, }, + ComAtprotoTempRequestPhoneVerification: { + lexicon: 1, + id: 'com.atproto.temp.requestPhoneVerification', + defs: { + main: { + type: 'procedure', + description: + 'Request a verification code to be sent to the supplied phone number', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['phonenumber'], + properties: { + phonenumber: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -7992,6 +8021,8 @@ export const ids = { 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', diff --git a/packages/api/src/client/types/com/atproto/server/createAccount.ts b/packages/api/src/client/types/com/atproto/server/createAccount.ts index 7631727ef19..2e3ec305f2b 100644 --- a/packages/api/src/client/types/com/atproto/server/createAccount.ts +++ b/packages/api/src/client/types/com/atproto/server/createAccount.ts @@ -14,6 +14,7 @@ export interface InputSchema { handle: string did?: string inviteCode?: string + verificationCode?: string password?: string recoveryKey?: string plcOp?: {} diff --git a/packages/api/src/client/types/com/atproto/server/describeServer.ts b/packages/api/src/client/types/com/atproto/server/describeServer.ts index ed3b870225d..fb6c9d5c662 100644 --- a/packages/api/src/client/types/com/atproto/server/describeServer.ts +++ b/packages/api/src/client/types/com/atproto/server/describeServer.ts @@ -13,6 +13,7 @@ export type InputSchema = undefined export interface OutputSchema { inviteCodeRequired?: boolean + phoneVerificationRequired?: boolean availableUserDomains: string[] links?: Links [k: string]: unknown diff --git a/packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts b/packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts new file mode 100644 index 00000000000..9144fc8b344 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts @@ -0,0 +1,32 @@ +/** + * 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 { + phonenumber: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 386f77196e7..1dd381f180f 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -77,6 +77,7 @@ import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscrib 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' @@ -161,39 +162,39 @@ export class Server { export class ComNS { _server: Server - atproto: AtprotoNS + atproto: ComAtprotoNS constructor(server: Server) { this._server = server - this.atproto = new AtprotoNS(server) + this.atproto = new ComAtprotoNS(server) } } -export class AtprotoNS { +export class ComAtprotoNS { _server: Server - admin: AdminNS - identity: IdentityNS - label: LabelNS - moderation: ModerationNS - repo: RepoNS - server: ServerNS - sync: SyncNS - temp: TempNS + admin: ComAtprotoAdminNS + identity: ComAtprotoIdentityNS + label: ComAtprotoLabelNS + moderation: ComAtprotoModerationNS + repo: ComAtprotoRepoNS + server: ComAtprotoServerNS + sync: ComAtprotoSyncNS + temp: ComAtprotoTempNS constructor(server: Server) { this._server = server - this.admin = new AdminNS(server) - this.identity = new IdentityNS(server) - this.label = new LabelNS(server) - this.moderation = new ModerationNS(server) - this.repo = new RepoNS(server) - this.server = new ServerNS(server) - this.sync = new SyncNS(server) - this.temp = new TempNS(server) + this.admin = new ComAtprotoAdminNS(server) + this.identity = new ComAtprotoIdentityNS(server) + this.label = new ComAtprotoLabelNS(server) + this.moderation = new ComAtprotoModerationNS(server) + this.repo = new ComAtprotoRepoNS(server) + this.server = new ComAtprotoServerNS(server) + this.sync = new ComAtprotoSyncNS(server) + this.temp = new ComAtprotoTempNS(server) } } -export class AdminNS { +export class ComAtprotoAdminNS { _server: Server constructor(server: Server) { @@ -410,7 +411,7 @@ export class AdminNS { } } -export class IdentityNS { +export class ComAtprotoIdentityNS { _server: Server constructor(server: Server) { @@ -440,7 +441,7 @@ export class IdentityNS { } } -export class LabelNS { +export class ComAtprotoLabelNS { _server: Server constructor(server: Server) { @@ -470,7 +471,7 @@ export class LabelNS { } } -export class ModerationNS { +export class ComAtprotoModerationNS { _server: Server constructor(server: Server) { @@ -489,7 +490,7 @@ export class ModerationNS { } } -export class RepoNS { +export class ComAtprotoRepoNS { _server: Server constructor(server: Server) { @@ -585,7 +586,7 @@ export class RepoNS { } } -export class ServerNS { +export class ComAtprotoServerNS { _server: Server constructor(server: Server) { @@ -824,7 +825,7 @@ export class ServerNS { } } -export class SyncNS { +export class ComAtprotoSyncNS { _server: Server constructor(server: Server) { @@ -964,7 +965,7 @@ export class SyncNS { } } -export class TempNS { +export class ComAtprotoTempNS { _server: Server constructor(server: Server) { @@ -1004,6 +1005,17 @@ export class TempNS { return this._server.xrpc.method(nsid, cfg) } + requestPhoneVerification( + cfg: ConfigOf< + AV, + ComAtprotoTempRequestPhoneVerification.Handler>, + ComAtprotoTempRequestPhoneVerification.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.temp.requestPhoneVerification' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + transferAccount( cfg: ConfigOf< AV, @@ -1018,37 +1030,37 @@ export class TempNS { export class AppNS { _server: Server - bsky: BskyNS + bsky: AppBskyNS constructor(server: Server) { this._server = server - this.bsky = new BskyNS(server) + this.bsky = new AppBskyNS(server) } } -export class BskyNS { +export class AppBskyNS { _server: Server - actor: ActorNS - embed: EmbedNS - feed: FeedNS - graph: GraphNS - notification: NotificationNS - richtext: RichtextNS - unspecced: UnspeccedNS + actor: AppBskyActorNS + embed: AppBskyEmbedNS + feed: AppBskyFeedNS + graph: AppBskyGraphNS + notification: AppBskyNotificationNS + richtext: AppBskyRichtextNS + unspecced: AppBskyUnspeccedNS constructor(server: Server) { this._server = server - this.actor = new ActorNS(server) - this.embed = new EmbedNS(server) - this.feed = new FeedNS(server) - this.graph = new GraphNS(server) - this.notification = new NotificationNS(server) - this.richtext = new RichtextNS(server) - this.unspecced = new UnspeccedNS(server) + this.actor = new AppBskyActorNS(server) + this.embed = new AppBskyEmbedNS(server) + this.feed = new AppBskyFeedNS(server) + this.graph = new AppBskyGraphNS(server) + this.notification = new AppBskyNotificationNS(server) + this.richtext = new AppBskyRichtextNS(server) + this.unspecced = new AppBskyUnspeccedNS(server) } } -export class ActorNS { +export class AppBskyActorNS { _server: Server constructor(server: Server) { @@ -1133,7 +1145,7 @@ export class ActorNS { } } -export class EmbedNS { +export class AppBskyEmbedNS { _server: Server constructor(server: Server) { @@ -1141,7 +1153,7 @@ export class EmbedNS { } } -export class FeedNS { +export class AppBskyFeedNS { _server: Server constructor(server: Server) { @@ -1325,7 +1337,7 @@ export class FeedNS { } } -export class GraphNS { +export class AppBskyGraphNS { _server: Server constructor(server: Server) { @@ -1476,7 +1488,7 @@ export class GraphNS { } } -export class NotificationNS { +export class AppBskyNotificationNS { _server: Server constructor(server: Server) { @@ -1528,7 +1540,7 @@ export class NotificationNS { } } -export class RichtextNS { +export class AppBskyRichtextNS { _server: Server constructor(server: Server) { @@ -1536,7 +1548,7 @@ export class RichtextNS { } } -export class UnspeccedNS { +export class AppBskyUnspeccedNS { _server: Server constructor(server: Server) { diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 258d297c69e..059c99be925 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2664,6 +2664,9 @@ export const schemaDict = { inviteCode: { type: 'string', }, + verificationCode: { + type: 'string', + }, password: { type: 'string', }, @@ -3068,6 +3071,9 @@ export const schemaDict = { inviteCodeRequired: { type: 'boolean', }, + phoneVerificationRequired: { + type: 'boolean', + }, availableUserDomains: { type: 'array', items: { @@ -4140,6 +4146,29 @@ export const schemaDict = { }, }, }, + ComAtprotoTempRequestPhoneVerification: { + lexicon: 1, + id: 'com.atproto.temp.requestPhoneVerification', + defs: { + main: { + type: 'procedure', + description: + 'Request a verification code to be sent to the supplied phone number', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['phonenumber'], + properties: { + phonenumber: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -7992,6 +8021,8 @@ export const ids = { 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', diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts index 109d34cf202..b7fa352007d 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts @@ -15,6 +15,7 @@ export interface InputSchema { handle: string did?: string inviteCode?: string + verificationCode?: string password?: string recoveryKey?: string plcOp?: {} diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/describeServer.ts b/packages/bsky/src/lexicon/types/com/atproto/server/describeServer.ts index bc73d541a04..bb574dba9ff 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/describeServer.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/describeServer.ts @@ -14,6 +14,7 @@ export type InputSchema = undefined export interface OutputSchema { inviteCodeRequired?: boolean + phoneVerificationRequired?: boolean availableUserDomains: string[] links?: Links [k: string]: unknown diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts new file mode 100644 index 00000000000..05aecc1574b --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + phonenumber: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +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/package.json b/packages/pds/package.json index 6cadab50dad..e575d094400 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -67,6 +67,7 @@ "pino": "^8.15.0", "pino-http": "^8.2.1", "sharp": "^0.32.6", + "twilio": "^4.20.1", "typed-emitter": "^2.1.0", "uint8arrays": "3.0.0", "zod": "^3.21.4" diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 6a20e523ff0..e7f8f1890c0 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -40,6 +40,30 @@ export default function (server: Server, ctx: AppContext) { const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) + if (ctx.cfg.phoneVerification.required && ctx.twilio) { + if (!input.body.verificationPhone) { + throw new InvalidRequestError( + 'Phone number verification is required on this server and none was provided.', + 'InvalidPhoneVerification', + ) + } else if (!input.body.verificationCode) { + throw new InvalidRequestError( + 'Phone number verification is required on this server and none was provided.', + 'InvalidPhoneVerification', + ) + } + const verified = await ctx.twilio.verifyCode( + input.body.verificationPhone, + input.body.verificationCode, + ) + if (!verified) { + throw new InvalidRequestError( + 'Could not verify phone number. Please try again.', + 'InvalidPhoneVerification', + ) + } + } + const result = await ctx.db.transaction(async (dbTxn) => { const actorTxn = ctx.services.account(dbTxn) const repoTxn = ctx.services.repo(dbTxn) diff --git a/packages/pds/src/api/com/atproto/server/describeServer.ts b/packages/pds/src/api/com/atproto/server/describeServer.ts index 0ad3b2d66eb..0314cf0d6d8 100644 --- a/packages/pds/src/api/com/atproto/server/describeServer.ts +++ b/packages/pds/src/api/com/atproto/server/describeServer.ts @@ -5,6 +5,7 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.server.describeServer(() => { const availableUserDomains = ctx.cfg.identity.serviceHandleDomains const inviteCodeRequired = ctx.cfg.invites.required + const phoneVerificationRequired = ctx.cfg.phoneVerification.required const privacyPolicy = ctx.cfg.service.privacyPolicyUrl const termsOfService = ctx.cfg.service.termsOfServiceUrl @@ -13,6 +14,7 @@ export default function (server: Server, ctx: AppContext) { body: { availableUserDomains, inviteCodeRequired, + phoneVerificationRequired, links: { privacyPolicy, termsOfService }, }, } diff --git a/packages/pds/src/api/com/atproto/temp/index.ts b/packages/pds/src/api/com/atproto/temp/index.ts new file mode 100644 index 00000000000..db34f17bf29 --- /dev/null +++ b/packages/pds/src/api/com/atproto/temp/index.ts @@ -0,0 +1,7 @@ +import AppContext from '../../../../context' +import { Server } from '../../../../lexicon' +import requestPhoneVerification from './requestPhoneVerification' + +export default function (server: Server, ctx: AppContext) { + requestPhoneVerification(server, ctx) +} diff --git a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts new file mode 100644 index 00000000000..9170b4c6cea --- /dev/null +++ b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts @@ -0,0 +1,25 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { HOUR, MINUTE } from '@atproto/common' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.temp.requestPhoneVerification({ + rateLimit: [ + { + durationMs: 5 * MINUTE, + points: 50, + }, + { + durationMs: HOUR, + points: 100, + }, + ], + handler: async ({ input }) => { + if (!ctx.twilio) { + throw new InvalidRequestError('phone verification not enabled') + } + await ctx.twilio.sendCode(input.body.phoneNumber) + }, + }) +} diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index d8ec5a031a2..5be9201f26e 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -126,6 +126,19 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { epoch: env.inviteEpoch ?? 0, } + let phoneVerificationCfg: ServerConfig['phoneVerification'] = { + required: false, + } + if (env.phoneVerificationRequired) { + assert(env.twilioAccountSid) + assert(env.twilioServiceSid) + phoneVerificationCfg = { + required: true, + twilioAccountSid: env.twilioAccountSid, + twilioServiceSid: env.twilioServiceSid, + } + } + let emailCfg: ServerConfig['email'] if (!env.emailFromAddress && !env.emailSmtpUrl) { emailCfg = null @@ -204,6 +217,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { blobstore: blobstoreCfg, identity: identityCfg, invites: invitesCfg, + phoneVerification: phoneVerificationCfg, email: emailCfg, moderationEmail: moderationEmailCfg, subscription: subscriptionCfg, @@ -221,6 +235,7 @@ export type ServerConfig = { blobstore: S3BlobstoreConfig | DiskBlobstoreConfig identity: IdentityConfig invites: InvitesConfig + phoneVerification: PhoneVerificationConfig email: EmailConfig | null moderationEmail: EmailConfig | null subscription: SubscriptionConfig @@ -300,6 +315,16 @@ export type InvitesConfig = required: false } +export type PhoneVerificationConfig = + | { + required: true + twilioAccountSid: string + twilioServiceSid: string + } + | { + required: false + } + export type EmailConfig = { smtpUrl: string fromAddress: string diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 0645e8b47f3..1bcfa11a3e7 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -49,6 +49,12 @@ export const readEnv = (): ServerEnvironment => { inviteInterval: envInt('PDS_INVITE_INTERVAL'), inviteEpoch: envInt('PDS_INVITE_EPOCH'), + // phone verification + phoneVerificationRequired: envBool('PDS_PHONE_VERIFICATION_REQUIRED'), + twilioAccountSid: envStr('PDS_TWILIO_ACCOUNT_SID'), + twilioAuthToken: envStr('PDS_TWILIO_AUTH_TOKEN'), + twilioServiceSid: envStr('TWILIO_SERVICE_SID'), + // email emailSmtpUrl: envStr('PDS_EMAIL_SMTP_URL'), emailFromAddress: envStr('PDS_EMAIL_FROM_ADDRESS'), @@ -156,6 +162,12 @@ export type ServerEnvironment = { inviteInterval?: number inviteEpoch?: number + // phone verification + phoneVerificationRequired?: boolean + twilioAccountSid?: string + twilioAuthToken?: string + twilioServiceSid?: string + // email emailSmtpUrl?: string emailFromAddress?: string diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index db9e188630b..cddc6b6b7e8 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -65,6 +65,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { moderatorPassword: env.moderatorPassword ?? env.adminPassword, triagePassword: env.triagePassword ?? env.moderatorPassword ?? env.adminPassword, + twilioAuthToken: env.twilioAuthToken, repoSigningKey, plcRotationKey, } @@ -77,6 +78,7 @@ export type ServerSecrets = { adminPassword: string moderatorPassword: string triagePassword: string + twilioAuthToken?: string repoSigningKey: SigningKeyKms | SigningKeyMemory plcRotationKey: SigningKeyKms | SigningKeyMemory } diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index bc2b1bd3a4b..15ee684e28b 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -21,6 +21,8 @@ import { DiskBlobStore } from './storage' import { getRedisClient } from './redis' import { RuntimeFlags } from './runtime-flags' import { PdsAgents } from './pds-agents' +import { TwilioClient } from './twilio' +import assert from 'assert' export type AppContextOptions = { db: Database @@ -43,6 +45,7 @@ export type AppContextOptions = { pdsAgents: PdsAgents repoSigningKey: crypto.Keypair plcRotationKey: crypto.Keypair + twilio?: TwilioClient cfg: ServerConfig } @@ -67,6 +70,7 @@ export class AppContext { public pdsAgents: PdsAgents public repoSigningKey: crypto.Keypair public plcRotationKey: crypto.Keypair + public twilio?: TwilioClient public cfg: ServerConfig constructor(opts: AppContextOptions) { @@ -90,6 +94,7 @@ export class AppContext { this.pdsAgents = opts.pdsAgents this.repoSigningKey = opts.repoSigningKey this.plcRotationKey = opts.plcRotationKey + this.twilio = opts.twilio this.cfg = opts.cfg } @@ -207,6 +212,16 @@ export class AppContext { crawlers, }) + let twilio: TwilioClient | undefined = undefined + if (cfg.phoneVerification.required) { + assert(secrets.twilioAuthToken) + twilio = new TwilioClient({ + accountSid: cfg.phoneVerification.twilioAccountSid, + serviceSid: cfg.phoneVerification.twilioServiceSid, + authToken: secrets.twilioAuthToken, + }) + } + const pdsAgents = new PdsAgents() return new AppContext({ @@ -230,6 +245,7 @@ export class AppContext { repoSigningKey, plcRotationKey, pdsAgents, + twilio, cfg, ...(overrides ?? {}), }) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 386f77196e7..1dd381f180f 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -77,6 +77,7 @@ import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscrib 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' @@ -161,39 +162,39 @@ export class Server { export class ComNS { _server: Server - atproto: AtprotoNS + atproto: ComAtprotoNS constructor(server: Server) { this._server = server - this.atproto = new AtprotoNS(server) + this.atproto = new ComAtprotoNS(server) } } -export class AtprotoNS { +export class ComAtprotoNS { _server: Server - admin: AdminNS - identity: IdentityNS - label: LabelNS - moderation: ModerationNS - repo: RepoNS - server: ServerNS - sync: SyncNS - temp: TempNS + admin: ComAtprotoAdminNS + identity: ComAtprotoIdentityNS + label: ComAtprotoLabelNS + moderation: ComAtprotoModerationNS + repo: ComAtprotoRepoNS + server: ComAtprotoServerNS + sync: ComAtprotoSyncNS + temp: ComAtprotoTempNS constructor(server: Server) { this._server = server - this.admin = new AdminNS(server) - this.identity = new IdentityNS(server) - this.label = new LabelNS(server) - this.moderation = new ModerationNS(server) - this.repo = new RepoNS(server) - this.server = new ServerNS(server) - this.sync = new SyncNS(server) - this.temp = new TempNS(server) + this.admin = new ComAtprotoAdminNS(server) + this.identity = new ComAtprotoIdentityNS(server) + this.label = new ComAtprotoLabelNS(server) + this.moderation = new ComAtprotoModerationNS(server) + this.repo = new ComAtprotoRepoNS(server) + this.server = new ComAtprotoServerNS(server) + this.sync = new ComAtprotoSyncNS(server) + this.temp = new ComAtprotoTempNS(server) } } -export class AdminNS { +export class ComAtprotoAdminNS { _server: Server constructor(server: Server) { @@ -410,7 +411,7 @@ export class AdminNS { } } -export class IdentityNS { +export class ComAtprotoIdentityNS { _server: Server constructor(server: Server) { @@ -440,7 +441,7 @@ export class IdentityNS { } } -export class LabelNS { +export class ComAtprotoLabelNS { _server: Server constructor(server: Server) { @@ -470,7 +471,7 @@ export class LabelNS { } } -export class ModerationNS { +export class ComAtprotoModerationNS { _server: Server constructor(server: Server) { @@ -489,7 +490,7 @@ export class ModerationNS { } } -export class RepoNS { +export class ComAtprotoRepoNS { _server: Server constructor(server: Server) { @@ -585,7 +586,7 @@ export class RepoNS { } } -export class ServerNS { +export class ComAtprotoServerNS { _server: Server constructor(server: Server) { @@ -824,7 +825,7 @@ export class ServerNS { } } -export class SyncNS { +export class ComAtprotoSyncNS { _server: Server constructor(server: Server) { @@ -964,7 +965,7 @@ export class SyncNS { } } -export class TempNS { +export class ComAtprotoTempNS { _server: Server constructor(server: Server) { @@ -1004,6 +1005,17 @@ export class TempNS { return this._server.xrpc.method(nsid, cfg) } + requestPhoneVerification( + cfg: ConfigOf< + AV, + ComAtprotoTempRequestPhoneVerification.Handler>, + ComAtprotoTempRequestPhoneVerification.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.temp.requestPhoneVerification' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + transferAccount( cfg: ConfigOf< AV, @@ -1018,37 +1030,37 @@ export class TempNS { export class AppNS { _server: Server - bsky: BskyNS + bsky: AppBskyNS constructor(server: Server) { this._server = server - this.bsky = new BskyNS(server) + this.bsky = new AppBskyNS(server) } } -export class BskyNS { +export class AppBskyNS { _server: Server - actor: ActorNS - embed: EmbedNS - feed: FeedNS - graph: GraphNS - notification: NotificationNS - richtext: RichtextNS - unspecced: UnspeccedNS + actor: AppBskyActorNS + embed: AppBskyEmbedNS + feed: AppBskyFeedNS + graph: AppBskyGraphNS + notification: AppBskyNotificationNS + richtext: AppBskyRichtextNS + unspecced: AppBskyUnspeccedNS constructor(server: Server) { this._server = server - this.actor = new ActorNS(server) - this.embed = new EmbedNS(server) - this.feed = new FeedNS(server) - this.graph = new GraphNS(server) - this.notification = new NotificationNS(server) - this.richtext = new RichtextNS(server) - this.unspecced = new UnspeccedNS(server) + this.actor = new AppBskyActorNS(server) + this.embed = new AppBskyEmbedNS(server) + this.feed = new AppBskyFeedNS(server) + this.graph = new AppBskyGraphNS(server) + this.notification = new AppBskyNotificationNS(server) + this.richtext = new AppBskyRichtextNS(server) + this.unspecced = new AppBskyUnspeccedNS(server) } } -export class ActorNS { +export class AppBskyActorNS { _server: Server constructor(server: Server) { @@ -1133,7 +1145,7 @@ export class ActorNS { } } -export class EmbedNS { +export class AppBskyEmbedNS { _server: Server constructor(server: Server) { @@ -1141,7 +1153,7 @@ export class EmbedNS { } } -export class FeedNS { +export class AppBskyFeedNS { _server: Server constructor(server: Server) { @@ -1325,7 +1337,7 @@ export class FeedNS { } } -export class GraphNS { +export class AppBskyGraphNS { _server: Server constructor(server: Server) { @@ -1476,7 +1488,7 @@ export class GraphNS { } } -export class NotificationNS { +export class AppBskyNotificationNS { _server: Server constructor(server: Server) { @@ -1528,7 +1540,7 @@ export class NotificationNS { } } -export class RichtextNS { +export class AppBskyRichtextNS { _server: Server constructor(server: Server) { @@ -1536,7 +1548,7 @@ export class RichtextNS { } } -export class UnspeccedNS { +export class AppBskyUnspeccedNS { _server: Server constructor(server: Server) { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 258d297c69e..1147b9fcb98 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2664,6 +2664,12 @@ export const schemaDict = { inviteCode: { type: 'string', }, + verificationCode: { + type: 'string', + }, + verificationPhone: { + type: 'string', + }, password: { type: 'string', }, @@ -3068,6 +3074,9 @@ export const schemaDict = { inviteCodeRequired: { type: 'boolean', }, + phoneVerificationRequired: { + type: 'boolean', + }, availableUserDomains: { type: 'array', items: { @@ -4140,6 +4149,29 @@ export const schemaDict = { }, }, }, + ComAtprotoTempRequestPhoneVerification: { + lexicon: 1, + id: 'com.atproto.temp.requestPhoneVerification', + defs: { + main: { + type: 'procedure', + description: + 'Request a verification code to be sent to the supplied phone number', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['phoneNumber'], + properties: { + phoneNumber: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -7992,6 +8024,8 @@ export const ids = { 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', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts index 109d34cf202..bbf2c009bf5 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts @@ -15,6 +15,8 @@ export interface InputSchema { handle: string did?: string inviteCode?: string + verificationCode?: string + verificationPhone?: string password?: string recoveryKey?: string plcOp?: {} diff --git a/packages/pds/src/lexicon/types/com/atproto/server/describeServer.ts b/packages/pds/src/lexicon/types/com/atproto/server/describeServer.ts index bc73d541a04..bb574dba9ff 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/describeServer.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/describeServer.ts @@ -14,6 +14,7 @@ export type InputSchema = undefined export interface OutputSchema { inviteCodeRequired?: boolean + phoneVerificationRequired?: boolean availableUserDomains: string[] links?: Links [k: string]: unknown diff --git a/packages/pds/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts b/packages/pds/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts new file mode 100644 index 00000000000..5a295f701eb --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + phoneNumber: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +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/twilio.ts b/packages/pds/src/twilio.ts new file mode 100644 index 00000000000..6237740ae44 --- /dev/null +++ b/packages/pds/src/twilio.ts @@ -0,0 +1,30 @@ +import twilio from 'twilio' + +type Opts = { + accountSid: string + serviceSid: string + authToken: string +} + +export class TwilioClient { + client: twilio.Twilio + serviceSid: string + + constructor(opts: Opts) { + this.client = twilio(opts.accountSid, opts.authToken) + this.serviceSid = opts.serviceSid + } + + async sendCode(phoneNumber: string) { + await this.client.verify.v2 + .services(this.serviceSid) + .verifications.create({ to: phoneNumber, channel: 'sms' }) + } + + async verifyCode(phoneNumber: string, code: string) { + const res = await this.client.verify.v2 + .services(this.serviceSid) + .verificationChecks.create({ to: phoneNumber, code }) + return res.status === 'approved' + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32c008c2091..dbd04f5c644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -573,6 +573,9 @@ importers: sharp: specifier: ^0.32.6 version: 0.32.6 + twilio: + specifier: ^4.20.1 + version: 4.20.1 typed-emitter: specifier: ^2.1.0 version: 2.1.0 @@ -5656,7 +5659,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /agentkeepalive@4.5.0: resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} @@ -5849,6 +5851,16 @@ packages: transitivePeerDependencies: - debug + /axios@1.6.5: + resolution: {integrity: sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==} + dependencies: + follow-redirects: 1.15.5 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /b4a@1.6.4: resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==} dev: false @@ -6091,6 +6103,10 @@ packages: node-int64: 0.4.0 dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -6485,6 +6501,10 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} dev: true + /dayjs@1.11.10: + resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} + dev: false + /dd-trace@3.13.2: resolution: {integrity: sha512-POO9nEcAufe5pgp2xV1X3PfWip6wh+6TpEcRSlSgZJCIIMvWVCkcIVL/J2a6KAZq6V3Yjbkl8Ktfe+MOzQf5kw==} engines: {node: '>=14'} @@ -6699,6 +6719,12 @@ packages: engines: {node: '>=10'} dev: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7533,6 +7559,16 @@ packages: debug: optional: true + /follow-redirects@1.15.5: + resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -7919,7 +7955,6 @@ packages: debug: 4.3.4 transitivePeerDependencies: - supports-color - dev: true /human-id@1.0.2: resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==} @@ -8804,6 +8839,37 @@ packages: graceful-fs: 4.2.11 dev: true + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.5.4 + dev: false + + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /key-encoder@2.0.3: resolution: {integrity: sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg==} dependencies: @@ -8894,10 +8960,34 @@ packages: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} dev: false + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} dev: false + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: false + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false + /lodash.kebabcase@4.1.1: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} dev: false @@ -8906,6 +8996,10 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /lodash.pick@4.4.0: resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==} dev: false @@ -9853,6 +9947,10 @@ packages: dependencies: side-channel: 1.0.4 + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: false + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -10044,6 +10142,10 @@ packages: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} dev: true + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + dev: false + /resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -10155,6 +10257,10 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} requiresBuild: true + /scmp@2.1.0: + resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + dev: false + /secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} dev: true @@ -10812,6 +10918,23 @@ packages: safe-buffer: 5.2.1 dev: false + /twilio@4.20.1: + resolution: {integrity: sha512-raoK6LKBtpaqPpaMamgQkNHnAgReW0rW3PS1Eow177f9yZV7lLw3UyqGctGcREMeWFLyH2kEDpgZG7vT1ezL9Q==} + engines: {node: '>=14.0'} + dependencies: + axios: 1.6.5 + dayjs: 1.11.10 + https-proxy-agent: 5.0.1 + jsonwebtoken: 9.0.2 + qs: 6.11.0 + scmp: 2.1.0 + url-parse: 1.5.10 + xmlbuilder: 13.0.2 + transitivePeerDependencies: + - debug + - supports-color + dev: false + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -11005,6 +11128,13 @@ packages: dependencies: punycode: 2.3.0 + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -11167,6 +11297,11 @@ packages: utf-8-validate: optional: true + /xmlbuilder@13.0.2: + resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==} + engines: {node: '>=6.0'} + dev: false + /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} From 3cd2768fb660083026be50fd4c3b9d53e7267a29 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 16 Jan 2024 18:12:52 -0600 Subject: [PATCH 045/135] track verified numbers in db --- .../api/com/atproto/server/createAccount.ts | 13 ++++++++++++ .../atproto/temp/requestPhoneVerification.ts | 20 +++++++++++++++++-- packages/pds/src/config/config.ts | 2 ++ packages/pds/src/config/env.ts | 2 ++ packages/pds/src/db/database-schema.ts | 4 +++- .../20240117T001106576Z-phone-verification.ts | 18 +++++++++++++++++ packages/pds/src/db/migrations/index.ts | 1 + .../pds/src/db/tables/phone-verification.ts | 8 ++++++++ 8 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 packages/pds/src/db/migrations/20240117T001106576Z-phone-verification.ts create mode 100644 packages/pds/src/db/tables/phone-verification.ts diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index e7f8f1890c0..034e31412ac 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -121,6 +121,19 @@ export default function (server: Server, ctx: AppContext) { .execute() } + if ( + ctx.cfg.phoneVerification.required && + input.body.verificationPhone + ) { + await dbTxn.db + .insertInto('phone_verification') + .values({ + did, + phoneNumber: input.body.verificationPhone, + }) + .execute() + } + const { access, refresh } = await ctx.services .auth(dbTxn) .createSession({ diff --git a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts index 9170b4c6cea..411d1073e6d 100644 --- a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts +++ b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts @@ -2,6 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { InvalidRequestError } from '@atproto/xrpc-server' import { HOUR, MINUTE } from '@atproto/common' +import { countAll } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.temp.requestPhoneVerification({ @@ -16,10 +17,25 @@ export default function (server: Server, ctx: AppContext) { }, ], handler: async ({ input }) => { - if (!ctx.twilio) { + if (!ctx.twilio || !ctx.cfg.phoneVerification.required) { throw new InvalidRequestError('phone verification not enabled') } - await ctx.twilio.sendCode(input.body.phoneNumber) + const accountsPerPhoneNumber = + ctx.cfg.phoneVerification.accountsPerPhoneNumber + const { phoneNumber } = input.body + + const res = await ctx.db.db + .selectFrom('phone_verification') + .select(countAll.as('count')) + .where('phoneNumber', '=', phoneNumber) + .executeTakeFirst() + if (res && res.count >= accountsPerPhoneNumber) { + throw new InvalidRequestError( + `There are too many accounts currently using this phone number. Max: ${accountsPerPhoneNumber}`, + ) + } + + await ctx.twilio.sendCode(phoneNumber) }, }) } diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 5be9201f26e..6652a2a29eb 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -136,6 +136,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { required: true, twilioAccountSid: env.twilioAccountSid, twilioServiceSid: env.twilioServiceSid, + accountsPerPhoneNumber: env.accountsPerPhoneNumber ?? 3, } } @@ -320,6 +321,7 @@ export type PhoneVerificationConfig = required: true twilioAccountSid: string twilioServiceSid: string + accountsPerPhoneNumber: number } | { required: false diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 1bcfa11a3e7..1dd16a2c2a0 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -51,6 +51,7 @@ export const readEnv = (): ServerEnvironment => { // phone verification phoneVerificationRequired: envBool('PDS_PHONE_VERIFICATION_REQUIRED'), + accountsPerPhoneNumber: envInt('PDS_ACCOUNTS_PER_PHONE_NUMBER'), twilioAccountSid: envStr('PDS_TWILIO_ACCOUNT_SID'), twilioAuthToken: envStr('PDS_TWILIO_AUTH_TOKEN'), twilioServiceSid: envStr('TWILIO_SERVICE_SID'), @@ -164,6 +165,7 @@ export type ServerEnvironment = { // phone verification phoneVerificationRequired?: boolean + accountsPerPhoneNumber?: number twilioAccountSid?: string twilioAuthToken?: string twilioServiceSid?: string diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 171c24d455d..57599aa12d9 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -18,6 +18,7 @@ import * as moderation from './tables/moderation' import * as repoSeq from './tables/repo-seq' import * as appMigration from './tables/app-migration' import * as runtimeFlag from './tables/runtime-flag' +import * as phoneVerification from './tables/phone-verification' export type DatabaseSchemaType = appMigration.PartialDB & runtimeFlag.PartialDB & @@ -37,7 +38,8 @@ export type DatabaseSchemaType = appMigration.PartialDB & repoBlob.PartialDB & emailToken.PartialDB & moderation.PartialDB & - repoSeq.PartialDB + repoSeq.PartialDB & + phoneVerification.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/pds/src/db/migrations/20240117T001106576Z-phone-verification.ts b/packages/pds/src/db/migrations/20240117T001106576Z-phone-verification.ts new file mode 100644 index 00000000000..3f28552a253 --- /dev/null +++ b/packages/pds/src/db/migrations/20240117T001106576Z-phone-verification.ts @@ -0,0 +1,18 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('phone_verification') + .addColumn('did', 'varchar', (col) => col.primaryKey()) + .addColumn('phoneNumber', 'varchar', (col) => col.notNull()) + .execute() + await db.schema + .createIndex('phone_verification_number_idx') + .on('phone_verification') + .column('phoneNumber') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('phone_verification').execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index 46160dba8fa..8b1e10f89f7 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -8,3 +8,4 @@ export * as _20230926T195532354Z from './20230926T195532354Z-email-tokens' export * as _20230929T213219699Z from './20230929T213219699Z-takedown-id-as-int' export * as _20231011T155513453Z from './20231011T155513453Z-takedown-ref' export * as _20231031T222409283Z from './20231031T222409283Z-user-account-pds' +export * as _20240117T001106576Z from './20240117T001106576Z-phone-verification' diff --git a/packages/pds/src/db/tables/phone-verification.ts b/packages/pds/src/db/tables/phone-verification.ts new file mode 100644 index 00000000000..049ee204bf6 --- /dev/null +++ b/packages/pds/src/db/tables/phone-verification.ts @@ -0,0 +1,8 @@ +export interface PhoneVerification { + did: string + phoneNumber: string +} + +export const tableName = 'phone_verification' + +export type PartialDB = { [tableName]: PhoneVerification } From d0771c2cd2cad452ca26050b1a3ca06ead749d69 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 16 Jan 2024 19:25:25 -0600 Subject: [PATCH 046/135] codegen + tests --- packages/api/src/client/lexicons.ts | 7 +- .../types/com/atproto/server/createAccount.ts | 1 + .../atproto/temp/requestPhoneVerification.ts | 2 +- packages/bsky/src/lexicon/lexicons.ts | 7 +- .../types/com/atproto/server/createAccount.ts | 1 + .../atproto/temp/requestPhoneVerification.ts | 2 +- packages/pds/src/api/com/atproto/index.ts | 2 + packages/pds/src/twilio.ts | 25 +-- packages/pds/tests/phone-verification.test.ts | 156 ++++++++++++++++++ 9 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 packages/pds/tests/phone-verification.test.ts diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 059c99be925..1147b9fcb98 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2667,6 +2667,9 @@ export const schemaDict = { verificationCode: { type: 'string', }, + verificationPhone: { + type: 'string', + }, password: { type: 'string', }, @@ -4158,9 +4161,9 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['phonenumber'], + required: ['phoneNumber'], properties: { - phonenumber: { + phoneNumber: { type: 'string', }, }, diff --git a/packages/api/src/client/types/com/atproto/server/createAccount.ts b/packages/api/src/client/types/com/atproto/server/createAccount.ts index 2e3ec305f2b..b62adf97cb1 100644 --- a/packages/api/src/client/types/com/atproto/server/createAccount.ts +++ b/packages/api/src/client/types/com/atproto/server/createAccount.ts @@ -15,6 +15,7 @@ export interface InputSchema { did?: string inviteCode?: string verificationCode?: string + verificationPhone?: string password?: string recoveryKey?: string plcOp?: {} diff --git a/packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts b/packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts index 9144fc8b344..06a8972599d 100644 --- a/packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts +++ b/packages/api/src/client/types/com/atproto/temp/requestPhoneVerification.ts @@ -10,7 +10,7 @@ import { CID } from 'multiformats/cid' export interface QueryParams {} export interface InputSchema { - phonenumber: string + phoneNumber: string [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 059c99be925..1147b9fcb98 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2667,6 +2667,9 @@ export const schemaDict = { verificationCode: { type: 'string', }, + verificationPhone: { + type: 'string', + }, password: { type: 'string', }, @@ -4158,9 +4161,9 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['phonenumber'], + required: ['phoneNumber'], properties: { - phonenumber: { + phoneNumber: { type: 'string', }, }, diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts index b7fa352007d..bbf2c009bf5 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts @@ -16,6 +16,7 @@ export interface InputSchema { did?: string inviteCode?: string verificationCode?: string + verificationPhone?: string password?: string recoveryKey?: string plcOp?: {} diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts index 05aecc1574b..5a295f701eb 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/requestPhoneVerification.ts @@ -11,7 +11,7 @@ import { HandlerAuth } from '@atproto/xrpc-server' export interface QueryParams {} export interface InputSchema { - phonenumber: string + phoneNumber: string [k: string]: unknown } diff --git a/packages/pds/src/api/com/atproto/index.ts b/packages/pds/src/api/com/atproto/index.ts index a5c26c80495..c7d4f217f88 100644 --- a/packages/pds/src/api/com/atproto/index.ts +++ b/packages/pds/src/api/com/atproto/index.ts @@ -6,6 +6,7 @@ import moderation from './moderation' import repo from './repo' import serverMethods from './server' import sync from './sync' +import temp from './temp' export default function (server: Server, ctx: AppContext) { admin(server, ctx) @@ -14,4 +15,5 @@ export default function (server: Server, ctx: AppContext) { repo(server, ctx) serverMethods(server, ctx) sync(server, ctx) + temp(server, ctx) } diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index 6237740ae44..6a534e77cd2 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -6,25 +6,30 @@ type Opts = { authToken: string } +type VerifyClient = ReturnType + export class TwilioClient { - client: twilio.Twilio - serviceSid: string + verifyClient: VerifyClient constructor(opts: Opts) { - this.client = twilio(opts.accountSid, opts.authToken) - this.serviceSid = opts.serviceSid + this.verifyClient = twilio( + opts.accountSid, + opts.authToken, + ).verify.v2.services(opts.serviceSid) } async sendCode(phoneNumber: string) { - await this.client.verify.v2 - .services(this.serviceSid) - .verifications.create({ to: phoneNumber, channel: 'sms' }) + await this.verifyClient.verifications.create({ + to: phoneNumber, + channel: 'sms', + }) } async verifyCode(phoneNumber: string, code: string) { - const res = await this.client.verify.v2 - .services(this.serviceSid) - .verificationChecks.create({ to: phoneNumber, code }) + const res = await this.verifyClient.verificationChecks.create({ + to: phoneNumber, + code, + }) return res.status === 'approved' } } diff --git a/packages/pds/tests/phone-verification.test.ts b/packages/pds/tests/phone-verification.test.ts new file mode 100644 index 00000000000..a4ef1af5d26 --- /dev/null +++ b/packages/pds/tests/phone-verification.test.ts @@ -0,0 +1,156 @@ +import assert from 'assert' +import AtpAgent from '@atproto/api' +import * as crypto from '@atproto/crypto' +import { TestNetworkNoAppView } from '@atproto/dev-env' +import { AppContext } from '../src' + +describe('phone verification', () => { + let network: TestNetworkNoAppView + let ctx: AppContext + let agent: AtpAgent + + let verificationCodes: Record + let sentCodes: { number: string; code: string }[] + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'phone_verification', + pds: { + phoneVerificationRequired: true, + twilioAccountSid: 'ACXXXXXXX', + twilioAuthToken: 'AUTH', + twilioServiceSid: 'VAXXXXXXXX', + }, + }) + ctx = network.pds.ctx + assert(ctx.twilio) + verificationCodes = {} + sentCodes = [] + ctx.twilio.sendCode = async (number: string) => { + if (!verificationCodes[number]) { + const code = crypto.randomStr(4, 'base10').slice(0, 6) + verificationCodes[number] = code + } + const code = verificationCodes[number] + sentCodes.push({ code, number }) + } + ctx.twilio.verifyCode = async (number: string, code: string) => { + if (verificationCodes[number] === code) { + delete verificationCodes[number] + return true + } + return false + } + + agent = network.pds.getClient() + }) + + afterAll(async () => { + await network.close() + }) + + const requestCode = async (phoneNumber: string) => { + await agent.api.com.atproto.temp.requestPhoneVerification({ + phoneNumber, + }) + const sent = sentCodes.at(-1) + assert(sent) + assert(sent.number === phoneNumber) + return sent.code + } + + const createAccountWithCode = async (phoneNumber?: string, code?: string) => { + const name = crypto.randomStr(5, 'base32') + const res = await agent.api.com.atproto.server.createAccount({ + email: `${name}@test.com`, + handle: `${name}.test`, + password: name, + verificationPhone: phoneNumber, + verificationCode: code, + }) + return { + ...res.data, + password: name, + } + } + + it('describes the fact that invites are required', async () => { + const res = await agent.api.com.atproto.server.describeServer({}) + expect(res.data.phoneVerificationRequired).toBe(true) + }) + + const aliceNumber = '+11234567890' + let aliceCode: string + let aliceDid: string + + it('requests a phone verification code', async () => { + aliceCode = await requestCode(aliceNumber) + }) + + it('resends a phone verification code', async () => { + const resent = await requestCode(aliceNumber) + expect(resent).toEqual(aliceCode) + }) + + it('allows signup using a valid phone verification code', async () => { + const res = await createAccountWithCode(aliceNumber, aliceCode) + aliceDid = res.did + }) + + it('stores the associated phone number of an account', async () => { + const res = await ctx.db.db + .selectFrom('phone_verification') + .selectAll() + .where('did', '=', aliceDid) + .execute() + expect(res.length).toBe(1) + expect(res[0].phoneNumber).toBe(aliceNumber) + }) + + it('does not allow signup with an already used code', async () => { + const attempt = createAccountWithCode(aliceNumber, aliceCode) + await expect(attempt).rejects.toThrow( + 'Could not verify phone number. Please try again.', + ) + }) + + it('does not allow signup with out a code', async () => { + const attempt = createAccountWithCode() + await expect(attempt).rejects.toThrow( + 'Phone number verification is required on this server and none was provided.', + ) + }) + + it('does not allow signup when missing a code or a phone number', async () => { + const bobNumber = '+1098765432' + const bobCode = await requestCode(bobNumber) + const attempt = createAccountWithCode(undefined, bobCode) + await expect(attempt).rejects.toThrow( + 'Phone number verification is required on this server and none was provided.', + ) + const attempt2 = createAccountWithCode(bobNumber, undefined) + await expect(attempt2).rejects.toThrow( + 'Phone number verification is required on this server and none was provided.', + ) + }) + + it('does not allow signup with a valid code and a mismatched phone number', async () => { + const carolCode = await requestCode('+11111111111') + const attempt = createAccountWithCode('+12222222222', carolCode) + await expect(attempt).rejects.toThrow( + 'Could not verify phone number. Please try again.', + ) + }) + + it('does not allow more than the configured number of signups from the same code', async () => { + const danNumber = '+3333333333' + for (let i = 0; i < 3; i++) { + const danCode = await requestCode(danNumber) + await createAccountWithCode(danNumber, danCode) + } + const attempt = requestCode(danNumber) + await expect(attempt).rejects.toThrow( + `There are too many accounts currently using this phone number. Max: 3`, + ) + }) +}) From 5bc46d8fc8387e4ea5c86d5e0b5e061e49aca1d8 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 16 Jan 2024 19:41:22 -0600 Subject: [PATCH 047/135] add phone verification to dev-env --- packages/dev-env/src/bin.ts | 7 ++++++- packages/dev-env/src/mock/index.ts | 11 +++++++++++ packages/dev-env/src/pds.ts | 2 ++ packages/dev-env/src/util.ts | 22 ++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/dev-env/src/bin.ts b/packages/dev-env/src/bin.ts index 12228579a48..82e768ed1f0 100644 --- a/packages/dev-env/src/bin.ts +++ b/packages/dev-env/src/bin.ts @@ -1,7 +1,7 @@ import './env' import { generateMockSetup } from './mock' import { TestNetwork } from './network' -import { mockMailer } from './util' +import { mockMailer, mockTwilio } from './util' const run = async () => { console.log(` @@ -20,6 +20,10 @@ const run = async () => { hostname: 'localhost', dbPostgresSchema: 'pds', enableDidDocWithSession: true, + phoneVerificationRequired: true, + twilioAccountSid: 'ACXXXXXXX', + twilioAuthToken: 'AUTH', + twilioServiceSid: 'VAXXXXXXXX', }, bsky: { dbPostgresSchema: 'bsky', @@ -27,6 +31,7 @@ const run = async () => { plc: { port: 2582 }, }) mockMailer(network.pds) + mockTwilio(network.pds) await generateMockSetup(network) console.log( diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index 10f76b1c259..93b2f4717c4 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -76,10 +76,21 @@ export async function generateMockSetup(env: TestNetwork) { let _i = 1 for (const user of users) { + let verificationCode: string | undefined = undefined + let verificationPhone: string | undefined = undefined + if (env.pds.ctx.twilio) { + verificationPhone = `+1111111111${_i}` + await clients.loggedout.api.com.atproto.temp.requestPhoneVerification({ + phoneNumber: verificationPhone, + }) + verificationCode = env.pds.mockedPhoneCodes[verificationPhone] + } const res = await clients.loggedout.api.com.atproto.server.createAccount({ email: user.email, handle: user.handle, password: user.password, + verificationCode, + verificationPhone, }) user.agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) user.did = res.data.did diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index a4b67166e07..e9b7cd7c692 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -10,6 +10,8 @@ import { uniqueLockId } from './util' import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const' export class TestPds { + mockedPhoneCodes: Record = {} + constructor( public url: string, public port: number, diff --git a/packages/dev-env/src/util.ts b/packages/dev-env/src/util.ts index da4762be0c3..e76a31afada 100644 --- a/packages/dev-env/src/util.ts +++ b/packages/dev-env/src/util.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import * as crypto from '@atproto/crypto' import { IdResolver } from '@atproto/identity' import { TestPds } from './pds' import { TestBsky } from './bsky' @@ -76,3 +77,24 @@ export const uniqueLockId = () => { usedLockIds.add(lockId) return lockId } + +export const mockTwilio = (pds: TestPds) => { + if (!pds.ctx.twilio) return + + pds.ctx.twilio.sendCode = async (number: string) => { + if (!pds.mockedPhoneCodes[number]) { + const code = crypto.randomStr(4, 'base10').slice(0, 6) + pds.mockedPhoneCodes[number] = code + } + const code = pds.mockedPhoneCodes[number] + console.log(`☎️ Phone verification code sent to ${number}: ${code}`) + } + + pds.ctx.twilio.verifyCode = async (number: string, code: string) => { + if (pds.mockedPhoneCodes[number] === code) { + delete pds.mockedPhoneCodes[number] + return true + } + return false + } +} From cd5af038a3d8c589136a9ca2673fb1fd0cf5ede4 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 16 Jan 2024 19:45:57 -0600 Subject: [PATCH 048/135] fix codegen --- packages/api/src/client/index.ts | 102 ++++++++++++++-------------- packages/bsky/src/lexicon/index.ts | 104 ++++++++++++++--------------- packages/pds/src/lexicon/index.ts | 104 ++++++++++++++--------------- 3 files changed, 153 insertions(+), 157 deletions(-) diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index bfb1be016c9..6dfe88b2b1a 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -340,39 +340,39 @@ export class AtpServiceClient { export class ComNS { _service: AtpServiceClient - atproto: ComAtprotoNS + atproto: AtprotoNS constructor(service: AtpServiceClient) { this._service = service - this.atproto = new ComAtprotoNS(service) + this.atproto = new AtprotoNS(service) } } -export class ComAtprotoNS { +export class AtprotoNS { _service: AtpServiceClient - admin: ComAtprotoAdminNS - identity: ComAtprotoIdentityNS - label: ComAtprotoLabelNS - moderation: ComAtprotoModerationNS - repo: ComAtprotoRepoNS - server: ComAtprotoServerNS - sync: ComAtprotoSyncNS - temp: ComAtprotoTempNS + admin: AdminNS + identity: IdentityNS + label: LabelNS + moderation: ModerationNS + repo: RepoNS + server: ServerNS + sync: SyncNS + temp: TempNS constructor(service: AtpServiceClient) { this._service = service - this.admin = new ComAtprotoAdminNS(service) - this.identity = new ComAtprotoIdentityNS(service) - this.label = new ComAtprotoLabelNS(service) - this.moderation = new ComAtprotoModerationNS(service) - this.repo = new ComAtprotoRepoNS(service) - this.server = new ComAtprotoServerNS(service) - this.sync = new ComAtprotoSyncNS(service) - this.temp = new ComAtprotoTempNS(service) + this.admin = new AdminNS(service) + this.identity = new IdentityNS(service) + this.label = new LabelNS(service) + this.moderation = new ModerationNS(service) + this.repo = new RepoNS(service) + this.server = new ServerNS(service) + this.sync = new SyncNS(service) + this.temp = new TempNS(service) } } -export class ComAtprotoAdminNS { +export class AdminNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -594,7 +594,7 @@ export class ComAtprotoAdminNS { } } -export class ComAtprotoIdentityNS { +export class IdentityNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -624,7 +624,7 @@ export class ComAtprotoIdentityNS { } } -export class ComAtprotoLabelNS { +export class LabelNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -643,7 +643,7 @@ export class ComAtprotoLabelNS { } } -export class ComAtprotoModerationNS { +export class ModerationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -662,7 +662,7 @@ export class ComAtprotoModerationNS { } } -export class ComAtprotoRepoNS { +export class RepoNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -758,7 +758,7 @@ export class ComAtprotoRepoNS { } } -export class ComAtprotoServerNS { +export class ServerNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -997,7 +997,7 @@ export class ComAtprotoServerNS { } } -export class ComAtprotoSyncNS { +export class SyncNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1126,7 +1126,7 @@ export class ComAtprotoSyncNS { } } -export class ComAtprotoTempNS { +export class TempNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1191,37 +1191,37 @@ export class ComAtprotoTempNS { export class AppNS { _service: AtpServiceClient - bsky: AppBskyNS + bsky: BskyNS constructor(service: AtpServiceClient) { this._service = service - this.bsky = new AppBskyNS(service) + this.bsky = new BskyNS(service) } } -export class AppBskyNS { +export class BskyNS { _service: AtpServiceClient - actor: AppBskyActorNS - embed: AppBskyEmbedNS - feed: AppBskyFeedNS - graph: AppBskyGraphNS - notification: AppBskyNotificationNS - richtext: AppBskyRichtextNS - unspecced: AppBskyUnspeccedNS + actor: ActorNS + embed: EmbedNS + feed: FeedNS + graph: GraphNS + notification: NotificationNS + richtext: RichtextNS + unspecced: UnspeccedNS constructor(service: AtpServiceClient) { this._service = service - this.actor = new AppBskyActorNS(service) - this.embed = new AppBskyEmbedNS(service) - this.feed = new AppBskyFeedNS(service) - this.graph = new AppBskyGraphNS(service) - this.notification = new AppBskyNotificationNS(service) - this.richtext = new AppBskyRichtextNS(service) - this.unspecced = new AppBskyUnspeccedNS(service) + this.actor = new ActorNS(service) + this.embed = new EmbedNS(service) + this.feed = new FeedNS(service) + this.graph = new GraphNS(service) + this.notification = new NotificationNS(service) + this.richtext = new RichtextNS(service) + this.unspecced = new UnspeccedNS(service) } } -export class AppBskyActorNS { +export class ActorNS { _service: AtpServiceClient profile: ProfileRecord @@ -1369,7 +1369,7 @@ export class ProfileRecord { } } -export class AppBskyEmbedNS { +export class EmbedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1377,7 +1377,7 @@ export class AppBskyEmbedNS { } } -export class AppBskyFeedNS { +export class FeedNS { _service: AtpServiceClient generator: GeneratorRecord like: LikeRecord @@ -1880,7 +1880,7 @@ export class ThreadgateRecord { } } -export class AppBskyGraphNS { +export class GraphNS { _service: AtpServiceClient block: BlockRecord follow: FollowRecord @@ -2355,7 +2355,7 @@ export class ListitemRecord { } } -export class AppBskyNotificationNS { +export class NotificationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2407,7 +2407,7 @@ export class AppBskyNotificationNS { } } -export class AppBskyRichtextNS { +export class RichtextNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2415,7 +2415,7 @@ export class AppBskyRichtextNS { } } -export class AppBskyUnspeccedNS { +export class UnspeccedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 1dd381f180f..7e49495da5a 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -162,39 +162,39 @@ export class Server { export class ComNS { _server: Server - atproto: ComAtprotoNS + atproto: AtprotoNS constructor(server: Server) { this._server = server - this.atproto = new ComAtprotoNS(server) + this.atproto = new AtprotoNS(server) } } -export class ComAtprotoNS { +export class AtprotoNS { _server: Server - admin: ComAtprotoAdminNS - identity: ComAtprotoIdentityNS - label: ComAtprotoLabelNS - moderation: ComAtprotoModerationNS - repo: ComAtprotoRepoNS - server: ComAtprotoServerNS - sync: ComAtprotoSyncNS - temp: ComAtprotoTempNS + admin: AdminNS + identity: IdentityNS + label: LabelNS + moderation: ModerationNS + repo: RepoNS + server: ServerNS + sync: SyncNS + temp: TempNS constructor(server: Server) { this._server = server - this.admin = new ComAtprotoAdminNS(server) - this.identity = new ComAtprotoIdentityNS(server) - this.label = new ComAtprotoLabelNS(server) - this.moderation = new ComAtprotoModerationNS(server) - this.repo = new ComAtprotoRepoNS(server) - this.server = new ComAtprotoServerNS(server) - this.sync = new ComAtprotoSyncNS(server) - this.temp = new ComAtprotoTempNS(server) + this.admin = new AdminNS(server) + this.identity = new IdentityNS(server) + this.label = new LabelNS(server) + this.moderation = new ModerationNS(server) + this.repo = new RepoNS(server) + this.server = new ServerNS(server) + this.sync = new SyncNS(server) + this.temp = new TempNS(server) } } -export class ComAtprotoAdminNS { +export class AdminNS { _server: Server constructor(server: Server) { @@ -411,7 +411,7 @@ export class ComAtprotoAdminNS { } } -export class ComAtprotoIdentityNS { +export class IdentityNS { _server: Server constructor(server: Server) { @@ -441,7 +441,7 @@ export class ComAtprotoIdentityNS { } } -export class ComAtprotoLabelNS { +export class LabelNS { _server: Server constructor(server: Server) { @@ -471,7 +471,7 @@ export class ComAtprotoLabelNS { } } -export class ComAtprotoModerationNS { +export class ModerationNS { _server: Server constructor(server: Server) { @@ -490,7 +490,7 @@ export class ComAtprotoModerationNS { } } -export class ComAtprotoRepoNS { +export class RepoNS { _server: Server constructor(server: Server) { @@ -586,7 +586,7 @@ export class ComAtprotoRepoNS { } } -export class ComAtprotoServerNS { +export class ServerNS { _server: Server constructor(server: Server) { @@ -825,7 +825,7 @@ export class ComAtprotoServerNS { } } -export class ComAtprotoSyncNS { +export class SyncNS { _server: Server constructor(server: Server) { @@ -965,7 +965,7 @@ export class ComAtprotoSyncNS { } } -export class ComAtprotoTempNS { +export class TempNS { _server: Server constructor(server: Server) { @@ -1030,37 +1030,37 @@ export class ComAtprotoTempNS { export class AppNS { _server: Server - bsky: AppBskyNS + bsky: BskyNS constructor(server: Server) { this._server = server - this.bsky = new AppBskyNS(server) + this.bsky = new BskyNS(server) } } -export class AppBskyNS { +export class BskyNS { _server: Server - actor: AppBskyActorNS - embed: AppBskyEmbedNS - feed: AppBskyFeedNS - graph: AppBskyGraphNS - notification: AppBskyNotificationNS - richtext: AppBskyRichtextNS - unspecced: AppBskyUnspeccedNS + actor: ActorNS + embed: EmbedNS + feed: FeedNS + graph: GraphNS + notification: NotificationNS + richtext: RichtextNS + unspecced: UnspeccedNS constructor(server: Server) { this._server = server - this.actor = new AppBskyActorNS(server) - this.embed = new AppBskyEmbedNS(server) - this.feed = new AppBskyFeedNS(server) - this.graph = new AppBskyGraphNS(server) - this.notification = new AppBskyNotificationNS(server) - this.richtext = new AppBskyRichtextNS(server) - this.unspecced = new AppBskyUnspeccedNS(server) + this.actor = new ActorNS(server) + this.embed = new EmbedNS(server) + this.feed = new FeedNS(server) + this.graph = new GraphNS(server) + this.notification = new NotificationNS(server) + this.richtext = new RichtextNS(server) + this.unspecced = new UnspeccedNS(server) } } -export class AppBskyActorNS { +export class ActorNS { _server: Server constructor(server: Server) { @@ -1145,7 +1145,7 @@ export class AppBskyActorNS { } } -export class AppBskyEmbedNS { +export class EmbedNS { _server: Server constructor(server: Server) { @@ -1153,7 +1153,7 @@ export class AppBskyEmbedNS { } } -export class AppBskyFeedNS { +export class FeedNS { _server: Server constructor(server: Server) { @@ -1337,7 +1337,7 @@ export class AppBskyFeedNS { } } -export class AppBskyGraphNS { +export class GraphNS { _server: Server constructor(server: Server) { @@ -1488,7 +1488,7 @@ export class AppBskyGraphNS { } } -export class AppBskyNotificationNS { +export class NotificationNS { _server: Server constructor(server: Server) { @@ -1540,7 +1540,7 @@ export class AppBskyNotificationNS { } } -export class AppBskyRichtextNS { +export class RichtextNS { _server: Server constructor(server: Server) { @@ -1548,7 +1548,7 @@ export class AppBskyRichtextNS { } } -export class AppBskyUnspeccedNS { +export class UnspeccedNS { _server: Server constructor(server: Server) { @@ -1622,13 +1622,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 1dd381f180f..7e49495da5a 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -162,39 +162,39 @@ export class Server { export class ComNS { _server: Server - atproto: ComAtprotoNS + atproto: AtprotoNS constructor(server: Server) { this._server = server - this.atproto = new ComAtprotoNS(server) + this.atproto = new AtprotoNS(server) } } -export class ComAtprotoNS { +export class AtprotoNS { _server: Server - admin: ComAtprotoAdminNS - identity: ComAtprotoIdentityNS - label: ComAtprotoLabelNS - moderation: ComAtprotoModerationNS - repo: ComAtprotoRepoNS - server: ComAtprotoServerNS - sync: ComAtprotoSyncNS - temp: ComAtprotoTempNS + admin: AdminNS + identity: IdentityNS + label: LabelNS + moderation: ModerationNS + repo: RepoNS + server: ServerNS + sync: SyncNS + temp: TempNS constructor(server: Server) { this._server = server - this.admin = new ComAtprotoAdminNS(server) - this.identity = new ComAtprotoIdentityNS(server) - this.label = new ComAtprotoLabelNS(server) - this.moderation = new ComAtprotoModerationNS(server) - this.repo = new ComAtprotoRepoNS(server) - this.server = new ComAtprotoServerNS(server) - this.sync = new ComAtprotoSyncNS(server) - this.temp = new ComAtprotoTempNS(server) + this.admin = new AdminNS(server) + this.identity = new IdentityNS(server) + this.label = new LabelNS(server) + this.moderation = new ModerationNS(server) + this.repo = new RepoNS(server) + this.server = new ServerNS(server) + this.sync = new SyncNS(server) + this.temp = new TempNS(server) } } -export class ComAtprotoAdminNS { +export class AdminNS { _server: Server constructor(server: Server) { @@ -411,7 +411,7 @@ export class ComAtprotoAdminNS { } } -export class ComAtprotoIdentityNS { +export class IdentityNS { _server: Server constructor(server: Server) { @@ -441,7 +441,7 @@ export class ComAtprotoIdentityNS { } } -export class ComAtprotoLabelNS { +export class LabelNS { _server: Server constructor(server: Server) { @@ -471,7 +471,7 @@ export class ComAtprotoLabelNS { } } -export class ComAtprotoModerationNS { +export class ModerationNS { _server: Server constructor(server: Server) { @@ -490,7 +490,7 @@ export class ComAtprotoModerationNS { } } -export class ComAtprotoRepoNS { +export class RepoNS { _server: Server constructor(server: Server) { @@ -586,7 +586,7 @@ export class ComAtprotoRepoNS { } } -export class ComAtprotoServerNS { +export class ServerNS { _server: Server constructor(server: Server) { @@ -825,7 +825,7 @@ export class ComAtprotoServerNS { } } -export class ComAtprotoSyncNS { +export class SyncNS { _server: Server constructor(server: Server) { @@ -965,7 +965,7 @@ export class ComAtprotoSyncNS { } } -export class ComAtprotoTempNS { +export class TempNS { _server: Server constructor(server: Server) { @@ -1030,37 +1030,37 @@ export class ComAtprotoTempNS { export class AppNS { _server: Server - bsky: AppBskyNS + bsky: BskyNS constructor(server: Server) { this._server = server - this.bsky = new AppBskyNS(server) + this.bsky = new BskyNS(server) } } -export class AppBskyNS { +export class BskyNS { _server: Server - actor: AppBskyActorNS - embed: AppBskyEmbedNS - feed: AppBskyFeedNS - graph: AppBskyGraphNS - notification: AppBskyNotificationNS - richtext: AppBskyRichtextNS - unspecced: AppBskyUnspeccedNS + actor: ActorNS + embed: EmbedNS + feed: FeedNS + graph: GraphNS + notification: NotificationNS + richtext: RichtextNS + unspecced: UnspeccedNS constructor(server: Server) { this._server = server - this.actor = new AppBskyActorNS(server) - this.embed = new AppBskyEmbedNS(server) - this.feed = new AppBskyFeedNS(server) - this.graph = new AppBskyGraphNS(server) - this.notification = new AppBskyNotificationNS(server) - this.richtext = new AppBskyRichtextNS(server) - this.unspecced = new AppBskyUnspeccedNS(server) + this.actor = new ActorNS(server) + this.embed = new EmbedNS(server) + this.feed = new FeedNS(server) + this.graph = new GraphNS(server) + this.notification = new NotificationNS(server) + this.richtext = new RichtextNS(server) + this.unspecced = new UnspeccedNS(server) } } -export class AppBskyActorNS { +export class ActorNS { _server: Server constructor(server: Server) { @@ -1145,7 +1145,7 @@ export class AppBskyActorNS { } } -export class AppBskyEmbedNS { +export class EmbedNS { _server: Server constructor(server: Server) { @@ -1153,7 +1153,7 @@ export class AppBskyEmbedNS { } } -export class AppBskyFeedNS { +export class FeedNS { _server: Server constructor(server: Server) { @@ -1337,7 +1337,7 @@ export class AppBskyFeedNS { } } -export class AppBskyGraphNS { +export class GraphNS { _server: Server constructor(server: Server) { @@ -1488,7 +1488,7 @@ export class AppBskyGraphNS { } } -export class AppBskyNotificationNS { +export class NotificationNS { _server: Server constructor(server: Server) { @@ -1540,7 +1540,7 @@ export class AppBskyNotificationNS { } } -export class AppBskyRichtextNS { +export class RichtextNS { _server: Server constructor(server: Server) { @@ -1548,7 +1548,7 @@ export class AppBskyRichtextNS { } } -export class AppBskyUnspeccedNS { +export class UnspeccedNS { _server: Server constructor(server: Server) { @@ -1622,13 +1622,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } From e56003988404607cf3b8914753e32ab08a285417 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 Jan 2024 10:07:57 -0800 Subject: [PATCH 049/135] Pass createAccount params directly from the generated type --- packages/api/src/agent.ts | 7 +------ packages/api/src/types.ts | 9 +++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index aea3cce9d4b..de80c9de07d 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -89,12 +89,7 @@ export class AtpAgent { opts: AtpAgentCreateAccountOpts, ): Promise { try { - const res = await this.api.com.atproto.server.createAccount({ - handle: opts.handle, - password: opts.password, - email: opts.email, - inviteCode: opts.inviteCode, - }) + const res = await this.api.com.atproto.server.createAccount(opts) this.session = { accessJwt: res.data.accessJwt, refreshJwt: res.data.refreshJwt, diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index c0f78bfaafc..b68683681fd 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -1,3 +1,4 @@ +import { ComAtprotoServerCreateAccount } from './client' import { LabelPreference } from './moderation/types' /** @@ -36,12 +37,8 @@ export interface AtpAgentOpts { /** * AtpAgent createAccount() opts */ -export interface AtpAgentCreateAccountOpts { - email: string - password: string - handle: string - inviteCode?: string -} +export type AtpAgentCreateAccountOpts = + ComAtprotoServerCreateAccount.InputSchema /** * AtpAgent login() opts From e62a42cfb4f9f8007b4c02cc146b4eeec1d9e957 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 18 Jan 2024 20:13:54 -0600 Subject: [PATCH 050/135] ensure valid phone number --- packages/pds/src/twilio.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index 6a534e77cd2..868a0c53f81 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -1,3 +1,4 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' import twilio from 'twilio' type Opts = { @@ -18,7 +19,15 @@ export class TwilioClient { ).verify.v2.services(opts.serviceSid) } + ensureValidPhoneNumber(phoneNumber: string) { + const valid = /^\+[1-9]\d{1,14}$/.test(phoneNumber) + if (!valid) { + throw new InvalidRequestError('Invalid phone number') + } + } + async sendCode(phoneNumber: string) { + this.ensureValidPhoneNumber(phoneNumber) await this.verifyClient.verifications.create({ to: phoneNumber, channel: 'sms', @@ -26,6 +35,7 @@ export class TwilioClient { } async verifyCode(phoneNumber: string, code: string) { + this.ensureValidPhoneNumber(phoneNumber) const res = await this.verifyClient.verificationChecks.create({ to: phoneNumber, code, From acd230c04c8b3dd93bc9b69b7e9885d6d36d24d7 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 18 Jan 2024 20:19:51 -0600 Subject: [PATCH 051/135] test for normalization --- packages/pds/tests/phone-verification.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/pds/tests/phone-verification.test.ts b/packages/pds/tests/phone-verification.test.ts index a4ef1af5d26..74efc5cc4dc 100644 --- a/packages/pds/tests/phone-verification.test.ts +++ b/packages/pds/tests/phone-verification.test.ts @@ -143,7 +143,7 @@ describe('phone verification', () => { }) it('does not allow more than the configured number of signups from the same code', async () => { - const danNumber = '+3333333333' + const danNumber = '+13333333333' for (let i = 0; i < 3; i++) { const danCode = await requestCode(danNumber) await createAccountWithCode(danNumber, danCode) @@ -153,4 +153,11 @@ describe('phone verification', () => { `There are too many accounts currently using this phone number. Max: 3`, ) }) + + it('does not allow invalidly formatted phone numbers', async () => { + const eveNumber = '+1-444-444-4444' + expect(() => ctx.twilio?.ensureValidPhoneNumber(eveNumber)).toThrow( + 'Invalid phone number', + ) + }) }) From 08a2e4458c9967f0a82a48b1d8160d0d6a800572 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 18 Jan 2024 20:20:14 -0600 Subject: [PATCH 052/135] comment --- packages/pds/src/twilio.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index 868a0c53f81..667f951a103 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -20,6 +20,7 @@ export class TwilioClient { } ensureValidPhoneNumber(phoneNumber: string) { + // https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164 const valid = /^\+[1-9]\d{1,14}$/.test(phoneNumber) if (!valid) { throw new InvalidRequestError('Invalid phone number') From 7fef18eebda3803a6cb1bf19269c4cf4f51b794e Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 18 Jan 2024 20:21:48 -0600 Subject: [PATCH 053/135] couple more tests --- packages/pds/tests/phone-verification.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/pds/tests/phone-verification.test.ts b/packages/pds/tests/phone-verification.test.ts index 74efc5cc4dc..90d355adc39 100644 --- a/packages/pds/tests/phone-verification.test.ts +++ b/packages/pds/tests/phone-verification.test.ts @@ -155,8 +155,13 @@ describe('phone verification', () => { }) it('does not allow invalidly formatted phone numbers', async () => { - const eveNumber = '+1-444-444-4444' - expect(() => ctx.twilio?.ensureValidPhoneNumber(eveNumber)).toThrow( + expect(() => ctx.twilio?.ensureValidPhoneNumber('+1-444-444-4444')).toThrow( + 'Invalid phone number', + ) + expect(() => ctx.twilio?.ensureValidPhoneNumber('1-444-444-4444')).toThrow( + 'Invalid phone number', + ) + expect(() => ctx.twilio?.ensureValidPhoneNumber('444-444-4444')).toThrow( 'Invalid phone number', ) }) From 04ad9131eb573fb21998a9909c889f08412a8537 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 18 Jan 2024 20:49:15 -0600 Subject: [PATCH 054/135] add some error handling --- packages/pds/src/twilio.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index 667f951a103..661ce360b69 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -1,4 +1,4 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, UpstreamFailureError } from '@atproto/xrpc-server' import twilio from 'twilio' type Opts = { @@ -29,18 +29,26 @@ export class TwilioClient { async sendCode(phoneNumber: string) { this.ensureValidPhoneNumber(phoneNumber) - await this.verifyClient.verifications.create({ - to: phoneNumber, - channel: 'sms', - }) + try { + await this.verifyClient.verifications.create({ + to: phoneNumber, + channel: 'sms', + }) + } catch (err) { + throw new UpstreamFailureError('Could not send verification text') + } } async verifyCode(phoneNumber: string, code: string) { this.ensureValidPhoneNumber(phoneNumber) - const res = await this.verifyClient.verificationChecks.create({ - to: phoneNumber, - code, - }) - return res.status === 'approved' + try { + const res = await this.verifyClient.verificationChecks.create({ + to: phoneNumber, + code, + }) + return res.status === 'approved' + } catch (err) { + throw new UpstreamFailureError('Could not send verification text') + } } } From ca11d39b6d5963a99597a9a510b76445e959fdca Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 18 Jan 2024 21:14:14 -0600 Subject: [PATCH 055/135] phone number verification --- .../api/com/atproto/server/createAccount.ts | 15 +++++++------ .../atproto/temp/requestPhoneVerification.ts | 4 +++- packages/pds/src/twilio.ts | 15 +++++++++---- packages/pds/tests/phone-verification.test.ts | 22 +++++++++---------- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 034e31412ac..5786ff92868 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -40,6 +40,7 @@ export default function (server: Server, ctx: AppContext) { const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) + let verificationPhone: string | undefined = undefined if (ctx.cfg.phoneVerification.required && ctx.twilio) { if (!input.body.verificationPhone) { throw new InvalidRequestError( @@ -52,9 +53,12 @@ export default function (server: Server, ctx: AppContext) { 'InvalidPhoneVerification', ) } - const verified = await ctx.twilio.verifyCode( + verificationPhone = ctx.twilio.normalizePhoneNumber( input.body.verificationPhone, - input.body.verificationCode, + ) + const verified = await ctx.twilio.verifyCode( + verificationPhone, + input.body.verificationCode.trim(), ) if (!verified) { throw new InvalidRequestError( @@ -121,15 +125,12 @@ export default function (server: Server, ctx: AppContext) { .execute() } - if ( - ctx.cfg.phoneVerification.required && - input.body.verificationPhone - ) { + if (ctx.cfg.phoneVerification.required && verificationPhone) { await dbTxn.db .insertInto('phone_verification') .values({ did, - phoneNumber: input.body.verificationPhone, + phoneNumber: verificationPhone, }) .execute() } diff --git a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts index 411d1073e6d..4869d431514 100644 --- a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts +++ b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts @@ -22,7 +22,9 @@ export default function (server: Server, ctx: AppContext) { } const accountsPerPhoneNumber = ctx.cfg.phoneVerification.accountsPerPhoneNumber - const { phoneNumber } = input.body + const phoneNumber = ctx.twilio.normalizePhoneNumber( + input.body.phoneNumber, + ) const res = await ctx.db.db .selectFrom('phone_verification') diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index 661ce360b69..a2d0846e396 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -19,16 +19,24 @@ export class TwilioClient { ).verify.v2.services(opts.serviceSid) } - ensureValidPhoneNumber(phoneNumber: string) { + normalizePhoneNumber(phoneNumber: string) { + let normalized = phoneNumber.replaceAll(/\(|\)|-| /g, '') + if (!normalized.startsWith('+')) { + if (normalized.length === 10) { + normalized = '+1' + normalized + } else { + normalized = '+' + normalized + } + } // https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164 - const valid = /^\+[1-9]\d{1,14}$/.test(phoneNumber) + const valid = /^\+[1-9]\d{1,14}$/.test(normalized) if (!valid) { throw new InvalidRequestError('Invalid phone number') } + return normalized } async sendCode(phoneNumber: string) { - this.ensureValidPhoneNumber(phoneNumber) try { await this.verifyClient.verifications.create({ to: phoneNumber, @@ -40,7 +48,6 @@ export class TwilioClient { } async verifyCode(phoneNumber: string, code: string) { - this.ensureValidPhoneNumber(phoneNumber) try { const res = await this.verifyClient.verificationChecks.create({ to: phoneNumber, diff --git a/packages/pds/tests/phone-verification.test.ts b/packages/pds/tests/phone-verification.test.ts index 90d355adc39..525845309fb 100644 --- a/packages/pds/tests/phone-verification.test.ts +++ b/packages/pds/tests/phone-verification.test.ts @@ -55,7 +55,6 @@ describe('phone verification', () => { }) const sent = sentCodes.at(-1) assert(sent) - assert(sent.number === phoneNumber) return sent.code } @@ -154,15 +153,16 @@ describe('phone verification', () => { ) }) - it('does not allow invalidly formatted phone numbers', async () => { - expect(() => ctx.twilio?.ensureValidPhoneNumber('+1-444-444-4444')).toThrow( - 'Invalid phone number', - ) - expect(() => ctx.twilio?.ensureValidPhoneNumber('1-444-444-4444')).toThrow( - 'Invalid phone number', - ) - expect(() => ctx.twilio?.ensureValidPhoneNumber('444-444-4444')).toThrow( - 'Invalid phone number', - ) + it('normalizes phone numbers', async () => { + const code1 = await requestCode('+1 (444)444-4444') + expect(verificationCodes['+14444444444']).toEqual(code1) + const code2 = await requestCode('(555)555-5555') + expect(verificationCodes['+15555555555']).toEqual(code2) + const code3 = await requestCode('1(666)666-6666') + expect(verificationCodes['+16666666666']).toEqual(code3) + const attempt1 = requestCode('+1444444444444444') + await expect(attempt1).rejects.toThrow('Invalid phone number') + const attempt2 = requestCode('a44444444') + await expect(attempt2).rejects.toThrow('Invalid phone number') }) }) From 6bdf8744b0cf3b637558039de593ca175e742235 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Fri, 19 Jan 2024 17:04:27 -0500 Subject: [PATCH 056/135] Communication template admin endpoints on entryway (#2064) * port communication template lexicons into entryway * proxy comm template admin routes thru entryway --- .../admin/createCommunicationTemplate.json | 43 ++++ lexicons/com/atproto/admin/defs.json | 32 +++ .../admin/deleteCommunicationTemplate.json | 20 ++ .../admin/listCommunicationTemplates.json | 26 +++ .../admin/updateCommunicationTemplate.json | 50 +++++ packages/api/src/client/index.ts | 72 +++++++ packages/api/src/client/lexicons.ts | 199 ++++++++++++++++++ .../admin/createCommunicationTemplate.ts | 43 ++++ .../client/types/com/atproto/admin/defs.ts | 35 +++ .../admin/deleteCommunicationTemplate.ts | 32 +++ .../admin/listCommunicationTemplates.ts | 34 +++ .../admin/updateCommunicationTemplate.ts | 46 ++++ packages/bsky/src/lexicon/index.ts | 48 +++++ packages/bsky/src/lexicon/lexicons.ts | 199 ++++++++++++++++++ .../admin/createCommunicationTemplate.ts | 54 +++++ .../lexicon/types/com/atproto/admin/defs.ts | 35 +++ .../admin/deleteCommunicationTemplate.ts | 38 ++++ .../admin/listCommunicationTemplates.ts | 44 ++++ .../admin/updateCommunicationTemplate.ts | 57 +++++ .../admin/createCommunicationTemplate.ts | 20 ++ .../admin/deleteCommunicationTemplate.ts | 15 ++ .../pds/src/api/com/atproto/admin/index.ts | 8 + .../admin/listCommunicationTemplates.ts | 20 ++ .../admin/updateCommunicationTemplate.ts | 20 ++ packages/pds/src/lexicon/index.ts | 48 +++++ packages/pds/src/lexicon/lexicons.ts | 199 ++++++++++++++++++ .../admin/createCommunicationTemplate.ts | 54 +++++ .../lexicon/types/com/atproto/admin/defs.ts | 35 +++ .../admin/deleteCommunicationTemplate.ts | 38 ++++ .../admin/listCommunicationTemplates.ts | 44 ++++ .../admin/updateCommunicationTemplate.ts | 57 +++++ 31 files changed, 1665 insertions(+) create mode 100644 lexicons/com/atproto/admin/createCommunicationTemplate.json create mode 100644 lexicons/com/atproto/admin/deleteCommunicationTemplate.json create mode 100644 lexicons/com/atproto/admin/listCommunicationTemplates.json create mode 100644 lexicons/com/atproto/admin/updateCommunicationTemplate.json create mode 100644 packages/api/src/client/types/com/atproto/admin/createCommunicationTemplate.ts create mode 100644 packages/api/src/client/types/com/atproto/admin/deleteCommunicationTemplate.ts create mode 100644 packages/api/src/client/types/com/atproto/admin/listCommunicationTemplates.ts create mode 100644 packages/api/src/client/types/com/atproto/admin/updateCommunicationTemplate.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/listCommunicationTemplates.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts create mode 100644 packages/pds/src/api/com/atproto/admin/createCommunicationTemplate.ts create mode 100644 packages/pds/src/api/com/atproto/admin/deleteCommunicationTemplate.ts create mode 100644 packages/pds/src/api/com/atproto/admin/listCommunicationTemplates.ts create mode 100644 packages/pds/src/api/com/atproto/admin/updateCommunicationTemplate.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/listCommunicationTemplates.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts diff --git a/lexicons/com/atproto/admin/createCommunicationTemplate.json b/lexicons/com/atproto/admin/createCommunicationTemplate.json new file mode 100644 index 00000000000..d4546a0e213 --- /dev/null +++ b/lexicons/com/atproto/admin/createCommunicationTemplate.json @@ -0,0 +1,43 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.createCommunicationTemplate", + "defs": { + "main": { + "type": "procedure", + "description": "Administrative action to create a new, re-usable communication (email for now) template.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["subject", "contentMarkdown", "name"], + "properties": { + "name": { + "type": "string", + "description": "Name of the template." + }, + "contentMarkdown": { + "type": "string", + "description": "Content of the template, markdown supported, can contain variable placeholders." + }, + "subject": { + "type": "string", + "description": "Subject of the message, used in emails." + }, + "createdBy": { + "type": "string", + "format": "did", + "description": "DID of the user who is creating the template." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "com.atproto.admin.defs#communicationTemplateView" + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index 55a8b32be53..5a65ae31562 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -584,6 +584,38 @@ "description": "Additional comment about the outgoing comm." } } + }, + "communicationTemplateView": { + "type": "object", + "required": [ + "id", + "name", + "contentMarkdown", + "disabled", + "lastUpdatedBy", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string", "description": "Name of the template." }, + "subject": { + "type": "string", + "description": "Content of the template, can contain markdown and variable placeholders." + }, + "contentMarkdown": { + "type": "string", + "description": "Subject of the message, used in emails." + }, + "disabled": { "type": "boolean" }, + "lastUpdatedBy": { + "type": "string", + "format": "did", + "description": "DID of the user who last updated the template." + }, + "createdAt": { "type": "string", "format": "datetime" }, + "updatedAt": { "type": "string", "format": "datetime" } + } } } } diff --git a/lexicons/com/atproto/admin/deleteCommunicationTemplate.json b/lexicons/com/atproto/admin/deleteCommunicationTemplate.json new file mode 100644 index 00000000000..58861757c06 --- /dev/null +++ b/lexicons/com/atproto/admin/deleteCommunicationTemplate.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.deleteCommunicationTemplate", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a communication template.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/listCommunicationTemplates.json b/lexicons/com/atproto/admin/listCommunicationTemplates.json new file mode 100644 index 00000000000..74d5a399f15 --- /dev/null +++ b/lexicons/com/atproto/admin/listCommunicationTemplates.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.listCommunicationTemplates", + "defs": { + "main": { + "type": "query", + "description": "Get list of all communication templates.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["communicationTemplates"], + "properties": { + "communicationTemplates": { + "type": "array", + "items": { + "type": "ref", + "ref": "com.atproto.admin.defs#communicationTemplateView" + } + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/updateCommunicationTemplate.json b/lexicons/com/atproto/admin/updateCommunicationTemplate.json new file mode 100644 index 00000000000..0f7f3612000 --- /dev/null +++ b/lexicons/com/atproto/admin/updateCommunicationTemplate.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.updateCommunicationTemplate", + "defs": { + "main": { + "type": "procedure", + "description": "Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "ID of the template to be updated." + }, + "name": { + "type": "string", + "description": "Name of the template." + }, + "contentMarkdown": { + "type": "string", + "description": "Content of the template, markdown supported, can contain variable placeholders." + }, + "subject": { + "type": "string", + "description": "Subject of the message, used in emails." + }, + "updatedBy": { + "type": "string", + "format": "did", + "description": "DID of the user who is updating the template." + }, + "disabled": { + "type": "boolean" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "com.atproto.admin.defs#communicationTemplateView" + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index fb56cd251a0..c996a63b4ea 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -7,8 +7,10 @@ import { } from '@atproto/xrpc' import { schemas } from './lexicons' import { CID } from 'multiformats/cid' +import * as ComAtprotoAdminCreateCommunicationTemplate from './types/com/atproto/admin/createCommunicationTemplate' import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' +import * as ComAtprotoAdminDeleteCommunicationTemplate from './types/com/atproto/admin/deleteCommunicationTemplate' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' @@ -20,12 +22,14 @@ import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/ge import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' +import * as ComAtprotoAdminListCommunicationTemplates from './types/com/atproto/admin/listCommunicationTemplates' import * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents' import * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses' import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +import * as ComAtprotoAdminUpdateCommunicationTemplate from './types/com/atproto/admin/updateCommunicationTemplate' import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' @@ -147,8 +151,10 @@ import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecced/searchActorsSkeleton' import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' +export * as ComAtprotoAdminCreateCommunicationTemplate from './types/com/atproto/admin/createCommunicationTemplate' export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' export * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' +export * as ComAtprotoAdminDeleteCommunicationTemplate from './types/com/atproto/admin/deleteCommunicationTemplate' export * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' export * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' @@ -160,12 +166,14 @@ export * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/ge export * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' export * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' export * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' +export * as ComAtprotoAdminListCommunicationTemplates from './types/com/atproto/admin/listCommunicationTemplates' export * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents' export * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses' export * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' export * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' export * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' export * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +export * as ComAtprotoAdminUpdateCommunicationTemplate from './types/com/atproto/admin/updateCommunicationTemplate' export * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' export * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' export * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' @@ -377,6 +385,22 @@ export class AdminNS { this._service = service } + createCommunicationTemplate( + data?: ComAtprotoAdminCreateCommunicationTemplate.InputSchema, + opts?: ComAtprotoAdminCreateCommunicationTemplate.CallOptions, + ): Promise { + return this._service.xrpc + .call( + 'com.atproto.admin.createCommunicationTemplate', + opts?.qp, + data, + opts, + ) + .catch((e) => { + throw ComAtprotoAdminCreateCommunicationTemplate.toKnownErr(e) + }) + } + deleteAccount( data?: ComAtprotoAdminDeleteAccount.InputSchema, opts?: ComAtprotoAdminDeleteAccount.CallOptions, @@ -388,6 +412,22 @@ export class AdminNS { }) } + deleteCommunicationTemplate( + data?: ComAtprotoAdminDeleteCommunicationTemplate.InputSchema, + opts?: ComAtprotoAdminDeleteCommunicationTemplate.CallOptions, + ): Promise { + return this._service.xrpc + .call( + 'com.atproto.admin.deleteCommunicationTemplate', + opts?.qp, + data, + opts, + ) + .catch((e) => { + throw ComAtprotoAdminDeleteCommunicationTemplate.toKnownErr(e) + }) + } + disableAccountInvites( data?: ComAtprotoAdminDisableAccountInvites.InputSchema, opts?: ComAtprotoAdminDisableAccountInvites.CallOptions, @@ -509,6 +549,22 @@ export class AdminNS { }) } + listCommunicationTemplates( + params?: ComAtprotoAdminListCommunicationTemplates.QueryParams, + opts?: ComAtprotoAdminListCommunicationTemplates.CallOptions, + ): Promise { + return this._service.xrpc + .call( + 'com.atproto.admin.listCommunicationTemplates', + params, + undefined, + opts, + ) + .catch((e) => { + throw ComAtprotoAdminListCommunicationTemplates.toKnownErr(e) + }) + } + queryModerationEvents( params?: ComAtprotoAdminQueryModerationEvents.QueryParams, opts?: ComAtprotoAdminQueryModerationEvents.CallOptions, @@ -580,6 +636,22 @@ export class AdminNS { }) } + updateCommunicationTemplate( + data?: ComAtprotoAdminUpdateCommunicationTemplate.InputSchema, + opts?: ComAtprotoAdminUpdateCommunicationTemplate.CallOptions, + ): Promise { + return this._service.xrpc + .call( + 'com.atproto.admin.updateCommunicationTemplate', + opts?.qp, + data, + opts, + ) + .catch((e) => { + throw ComAtprotoAdminUpdateCommunicationTemplate.toKnownErr(e) + }) + } + updateSubjectStatus( data?: ComAtprotoAdminUpdateSubjectStatus.InputSchema, opts?: ComAtprotoAdminUpdateSubjectStatus.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 258d297c69e..09a586338ea 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -4,6 +4,51 @@ import { LexiconDoc, Lexicons } from '@atproto/lexicon' export const schemaDict = { + ComAtprotoAdminCreateCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.createCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: + 'Administrative action to create a new, re-usable communication (email for now) template.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject', 'contentMarkdown', 'name'], + properties: { + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + createdBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is creating the template.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, ComAtprotoAdminDefs: { lexicon: 1, id: 'com.atproto.admin.defs', @@ -850,6 +895,52 @@ export const schemaDict = { }, }, }, + communicationTemplateView: { + type: 'object', + required: [ + 'id', + 'name', + 'contentMarkdown', + 'disabled', + 'lastUpdatedBy', + 'createdAt', + 'updatedAt', + ], + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + subject: { + type: 'string', + description: + 'Content of the template, can contain markdown and variable placeholders.', + }, + contentMarkdown: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + disabled: { + type: 'boolean', + }, + lastUpdatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who last updated the template.', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + updatedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, }, }, ComAtprotoAdminDeleteAccount: { @@ -875,6 +966,28 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.deleteCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: 'Delete a communication template.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -1282,6 +1395,32 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminListCommunicationTemplates: { + lexicon: 1, + id: 'com.atproto.admin.listCommunicationTemplates', + defs: { + main: { + type: 'query', + description: 'Get list of all communication templates.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['communicationTemplates'], + properties: { + communicationTemplates: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminQueryModerationEvents: { lexicon: 1, id: 'com.atproto.admin.queryModerationEvents', @@ -1619,6 +1758,58 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminUpdateCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.updateCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: + 'Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + description: 'ID of the template to be updated.', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + updatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is updating the template.', + }, + disabled: { + type: 'boolean', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, ComAtprotoAdminUpdateSubjectStatus: { lexicon: 1, id: 'com.atproto.admin.updateSubjectStatus', @@ -7912,8 +8103,12 @@ export const schemaDict = { export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { + ComAtprotoAdminCreateCommunicationTemplate: + 'com.atproto.admin.createCommunicationTemplate', ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', + ComAtprotoAdminDeleteCommunicationTemplate: + 'com.atproto.admin.deleteCommunicationTemplate', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', @@ -7926,6 +8121,8 @@ export const ids = { ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', ComAtprotoAdminGetRepo: 'com.atproto.admin.getRepo', ComAtprotoAdminGetSubjectStatus: 'com.atproto.admin.getSubjectStatus', + ComAtprotoAdminListCommunicationTemplates: + 'com.atproto.admin.listCommunicationTemplates', ComAtprotoAdminQueryModerationEvents: 'com.atproto.admin.queryModerationEvents', ComAtprotoAdminQueryModerationStatuses: @@ -7934,6 +8131,8 @@ export const ids = { ComAtprotoAdminSendEmail: 'com.atproto.admin.sendEmail', ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail', ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle', + ComAtprotoAdminUpdateCommunicationTemplate: + 'com.atproto.admin.updateCommunicationTemplate', ComAtprotoAdminUpdateSubjectStatus: 'com.atproto.admin.updateSubjectStatus', ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle', ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle', diff --git a/packages/api/src/client/types/com/atproto/admin/createCommunicationTemplate.ts b/packages/api/src/client/types/com/atproto/admin/createCommunicationTemplate.ts new file mode 100644 index 00000000000..2efe4c22e1e --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/createCommunicationTemplate.ts @@ -0,0 +1,43 @@ +/** + * 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' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + /** Name of the template. */ + name: string + /** Content of the template, markdown supported, can contain variable placeholders. */ + contentMarkdown: string + /** Subject of the message, used in emails. */ + subject: string + /** DID of the user who is creating the template. */ + createdBy?: string + [k: string]: unknown +} + +export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index aea27e86905..da154f8a845 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -717,3 +717,38 @@ export function isModEventEmail(v: unknown): v is ModEventEmail { export function validateModEventEmail(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventEmail', v) } + +export interface CommunicationTemplateView { + id: string + /** Name of the template. */ + name: string + /** Content of the template, can contain markdown and variable placeholders. */ + subject?: string + /** Subject of the message, used in emails. */ + contentMarkdown: string + disabled: boolean + /** DID of the user who last updated the template. */ + lastUpdatedBy: string + createdAt: string + updatedAt: string + [k: string]: unknown +} + +export function isCommunicationTemplateView( + v: unknown, +): v is CommunicationTemplateView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#communicationTemplateView' + ) +} + +export function validateCommunicationTemplateView( + v: unknown, +): ValidationResult { + return lexicons.validate( + 'com.atproto.admin.defs#communicationTemplateView', + v, + ) +} diff --git a/packages/api/src/client/types/com/atproto/admin/deleteCommunicationTemplate.ts b/packages/api/src/client/types/com/atproto/admin/deleteCommunicationTemplate.ts new file mode 100644 index 00000000000..a5c4d55fdeb --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/deleteCommunicationTemplate.ts @@ -0,0 +1,32 @@ +/** + * 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 { + id: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +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/admin/listCommunicationTemplates.ts b/packages/api/src/client/types/com/atproto/admin/listCommunicationTemplates.ts new file mode 100644 index 00000000000..d37e4688b2b --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/listCommunicationTemplates.ts @@ -0,0 +1,34 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + communicationTemplates: ComAtprotoAdminDefs.CommunicationTemplateView[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/admin/updateCommunicationTemplate.ts b/packages/api/src/client/types/com/atproto/admin/updateCommunicationTemplate.ts new file mode 100644 index 00000000000..49c33338c11 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/updateCommunicationTemplate.ts @@ -0,0 +1,46 @@ +/** + * 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' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + /** ID of the template to be updated. */ + id: string + /** Name of the template. */ + name?: string + /** Content of the template, markdown supported, can contain variable placeholders. */ + contentMarkdown?: string + /** Subject of the message, used in emails. */ + subject?: string + /** DID of the user who is updating the template. */ + updatedBy?: string + disabled?: boolean + [k: string]: unknown +} + +export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 386f77196e7..917078e5bb0 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -9,7 +9,9 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminCreateCommunicationTemplate from './types/com/atproto/admin/createCommunicationTemplate' import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' +import * as ComAtprotoAdminDeleteCommunicationTemplate from './types/com/atproto/admin/deleteCommunicationTemplate' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' @@ -21,12 +23,14 @@ import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/ge import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' +import * as ComAtprotoAdminListCommunicationTemplates from './types/com/atproto/admin/listCommunicationTemplates' import * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents' import * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses' import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +import * as ComAtprotoAdminUpdateCommunicationTemplate from './types/com/atproto/admin/updateCommunicationTemplate' import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' @@ -200,6 +204,17 @@ export class AdminNS { this._server = server } + createCommunicationTemplate( + cfg: ConfigOf< + AV, + ComAtprotoAdminCreateCommunicationTemplate.Handler>, + ComAtprotoAdminCreateCommunicationTemplate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.createCommunicationTemplate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + deleteAccount( cfg: ConfigOf< AV, @@ -211,6 +226,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + deleteCommunicationTemplate( + cfg: ConfigOf< + AV, + ComAtprotoAdminDeleteCommunicationTemplate.Handler>, + ComAtprotoAdminDeleteCommunicationTemplate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.deleteCommunicationTemplate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + disableAccountInvites( cfg: ConfigOf< AV, @@ -332,6 +358,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + listCommunicationTemplates( + cfg: ConfigOf< + AV, + ComAtprotoAdminListCommunicationTemplates.Handler>, + ComAtprotoAdminListCommunicationTemplates.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.listCommunicationTemplates' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + queryModerationEvents( cfg: ConfigOf< AV, @@ -398,6 +435,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + updateCommunicationTemplate( + cfg: ConfigOf< + AV, + ComAtprotoAdminUpdateCommunicationTemplate.Handler>, + ComAtprotoAdminUpdateCommunicationTemplate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.updateCommunicationTemplate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + updateSubjectStatus( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 258d297c69e..09a586338ea 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -4,6 +4,51 @@ import { LexiconDoc, Lexicons } from '@atproto/lexicon' export const schemaDict = { + ComAtprotoAdminCreateCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.createCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: + 'Administrative action to create a new, re-usable communication (email for now) template.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject', 'contentMarkdown', 'name'], + properties: { + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + createdBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is creating the template.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, ComAtprotoAdminDefs: { lexicon: 1, id: 'com.atproto.admin.defs', @@ -850,6 +895,52 @@ export const schemaDict = { }, }, }, + communicationTemplateView: { + type: 'object', + required: [ + 'id', + 'name', + 'contentMarkdown', + 'disabled', + 'lastUpdatedBy', + 'createdAt', + 'updatedAt', + ], + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + subject: { + type: 'string', + description: + 'Content of the template, can contain markdown and variable placeholders.', + }, + contentMarkdown: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + disabled: { + type: 'boolean', + }, + lastUpdatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who last updated the template.', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + updatedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, }, }, ComAtprotoAdminDeleteAccount: { @@ -875,6 +966,28 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.deleteCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: 'Delete a communication template.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -1282,6 +1395,32 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminListCommunicationTemplates: { + lexicon: 1, + id: 'com.atproto.admin.listCommunicationTemplates', + defs: { + main: { + type: 'query', + description: 'Get list of all communication templates.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['communicationTemplates'], + properties: { + communicationTemplates: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminQueryModerationEvents: { lexicon: 1, id: 'com.atproto.admin.queryModerationEvents', @@ -1619,6 +1758,58 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminUpdateCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.updateCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: + 'Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + description: 'ID of the template to be updated.', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + updatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is updating the template.', + }, + disabled: { + type: 'boolean', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, ComAtprotoAdminUpdateSubjectStatus: { lexicon: 1, id: 'com.atproto.admin.updateSubjectStatus', @@ -7912,8 +8103,12 @@ export const schemaDict = { export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { + ComAtprotoAdminCreateCommunicationTemplate: + 'com.atproto.admin.createCommunicationTemplate', ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', + ComAtprotoAdminDeleteCommunicationTemplate: + 'com.atproto.admin.deleteCommunicationTemplate', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', @@ -7926,6 +8121,8 @@ export const ids = { ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', ComAtprotoAdminGetRepo: 'com.atproto.admin.getRepo', ComAtprotoAdminGetSubjectStatus: 'com.atproto.admin.getSubjectStatus', + ComAtprotoAdminListCommunicationTemplates: + 'com.atproto.admin.listCommunicationTemplates', ComAtprotoAdminQueryModerationEvents: 'com.atproto.admin.queryModerationEvents', ComAtprotoAdminQueryModerationStatuses: @@ -7934,6 +8131,8 @@ export const ids = { ComAtprotoAdminSendEmail: 'com.atproto.admin.sendEmail', ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail', ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle', + ComAtprotoAdminUpdateCommunicationTemplate: + 'com.atproto.admin.updateCommunicationTemplate', ComAtprotoAdminUpdateSubjectStatus: 'com.atproto.admin.updateSubjectStatus', ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle', ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle', diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts new file mode 100644 index 00000000000..d42a8f2ef1d --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts @@ -0,0 +1,54 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + /** Name of the template. */ + name: string + /** Content of the template, markdown supported, can contain variable placeholders. */ + contentMarkdown: string + /** Subject of the message, used in emails. */ + subject: string + /** DID of the user who is creating the template. */ + createdBy?: string + [k: string]: unknown +} + +export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView + +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 +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index 8236f848fa0..41be2ad96e7 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -717,3 +717,38 @@ export function isModEventEmail(v: unknown): v is ModEventEmail { export function validateModEventEmail(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventEmail', v) } + +export interface CommunicationTemplateView { + id: string + /** Name of the template. */ + name: string + /** Content of the template, can contain markdown and variable placeholders. */ + subject?: string + /** Subject of the message, used in emails. */ + contentMarkdown: string + disabled: boolean + /** DID of the user who last updated the template. */ + lastUpdatedBy: string + createdAt: string + updatedAt: string + [k: string]: unknown +} + +export function isCommunicationTemplateView( + v: unknown, +): v is CommunicationTemplateView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#communicationTemplateView' + ) +} + +export function validateCommunicationTemplateView( + v: unknown, +): ValidationResult { + return lexicons.validate( + 'com.atproto.admin.defs#communicationTemplateView', + v, + ) +} diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts new file mode 100644 index 00000000000..4bc6ec86fe4 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + id: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +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/admin/listCommunicationTemplates.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/listCommunicationTemplates.ts new file mode 100644 index 00000000000..cb479533d39 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/listCommunicationTemplates.ts @@ -0,0 +1,44 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + communicationTemplates: ComAtprotoAdminDefs.CommunicationTemplateView[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts new file mode 100644 index 00000000000..5dc5cecda4a --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts @@ -0,0 +1,57 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + /** ID of the template to be updated. */ + id: string + /** Name of the template. */ + name?: string + /** Content of the template, markdown supported, can contain variable placeholders. */ + contentMarkdown?: string + /** Subject of the message, used in emails. */ + subject?: string + /** DID of the user who is updating the template. */ + updatedBy?: string + disabled?: boolean + [k: string]: unknown +} + +export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView + +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 +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/api/com/atproto/admin/createCommunicationTemplate.ts b/packages/pds/src/api/com/atproto/admin/createCommunicationTemplate.ts new file mode 100644 index 00000000000..1ef04ebfbaf --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/createCommunicationTemplate.ts @@ -0,0 +1,20 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { authPassthru } from '../../../proxy' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.createCommunicationTemplate({ + auth: ctx.authVerifier.role, + handler: async ({ req, input }) => { + const { data: result } = + await ctx.moderationAgent.com.atproto.admin.createCommunicationTemplate( + input.body, + authPassthru(req, true), + ) + return { + encoding: 'application/json', + body: result, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/admin/deleteCommunicationTemplate.ts b/packages/pds/src/api/com/atproto/admin/deleteCommunicationTemplate.ts new file mode 100644 index 00000000000..2f497f9089e --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/deleteCommunicationTemplate.ts @@ -0,0 +1,15 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { authPassthru } from '../../../proxy' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.deleteCommunicationTemplate({ + auth: ctx.authVerifier.role, + handler: async ({ req, input }) => { + await ctx.moderationAgent.com.atproto.admin.deleteCommunicationTemplate( + input.body, + authPassthru(req, true), + ) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/admin/index.ts b/packages/pds/src/api/com/atproto/admin/index.ts index 3ff1bcdb517..16c441ffca1 100644 --- a/packages/pds/src/api/com/atproto/admin/index.ts +++ b/packages/pds/src/api/com/atproto/admin/index.ts @@ -17,6 +17,10 @@ import updateAccountHandle from './updateAccountHandle' import updateAccountEmail from './updateAccountEmail' import sendEmail from './sendEmail' import queryModerationStatuses from './queryModerationStatuses' +import createCommunicationTemplate from './createCommunicationTemplate' +import deleteCommunicationTemplate from './deleteCommunicationTemplate' +import updateCommunicationTemplate from './updateCommunicationTemplate' +import listCommunicationTemplates from './listCommunicationTemplates' export default function (server: Server, ctx: AppContext) { emitModerationEvent(server, ctx) @@ -36,4 +40,8 @@ export default function (server: Server, ctx: AppContext) { updateAccountHandle(server, ctx) updateAccountEmail(server, ctx) sendEmail(server, ctx) + listCommunicationTemplates(server, ctx) + createCommunicationTemplate(server, ctx) + updateCommunicationTemplate(server, ctx) + deleteCommunicationTemplate(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/admin/listCommunicationTemplates.ts b/packages/pds/src/api/com/atproto/admin/listCommunicationTemplates.ts new file mode 100644 index 00000000000..838b3a2b593 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/listCommunicationTemplates.ts @@ -0,0 +1,20 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { authPassthru } from '../../../proxy' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.listCommunicationTemplates({ + auth: ctx.authVerifier.role, + handler: async ({ req }) => { + const { data: result } = + await ctx.moderationAgent.com.atproto.admin.listCommunicationTemplates( + {}, + authPassthru(req, true), + ) + return { + encoding: 'application/json', + body: result, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/admin/updateCommunicationTemplate.ts b/packages/pds/src/api/com/atproto/admin/updateCommunicationTemplate.ts new file mode 100644 index 00000000000..8e6057510dd --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/updateCommunicationTemplate.ts @@ -0,0 +1,20 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { authPassthru } from '../../../proxy' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.updateCommunicationTemplate({ + auth: ctx.authVerifier.role, + handler: async ({ req, input }) => { + const { data: result } = + await ctx.moderationAgent.com.atproto.admin.updateCommunicationTemplate( + input.body, + authPassthru(req, true), + ) + return { + encoding: 'application/json', + body: result, + } + }, + }) +} diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 386f77196e7..917078e5bb0 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -9,7 +9,9 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminCreateCommunicationTemplate from './types/com/atproto/admin/createCommunicationTemplate' import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' +import * as ComAtprotoAdminDeleteCommunicationTemplate from './types/com/atproto/admin/deleteCommunicationTemplate' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent' @@ -21,12 +23,14 @@ import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/ge import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' +import * as ComAtprotoAdminListCommunicationTemplates from './types/com/atproto/admin/listCommunicationTemplates' import * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents' import * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses' import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +import * as ComAtprotoAdminUpdateCommunicationTemplate from './types/com/atproto/admin/updateCommunicationTemplate' import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' @@ -200,6 +204,17 @@ export class AdminNS { this._server = server } + createCommunicationTemplate( + cfg: ConfigOf< + AV, + ComAtprotoAdminCreateCommunicationTemplate.Handler>, + ComAtprotoAdminCreateCommunicationTemplate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.createCommunicationTemplate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + deleteAccount( cfg: ConfigOf< AV, @@ -211,6 +226,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + deleteCommunicationTemplate( + cfg: ConfigOf< + AV, + ComAtprotoAdminDeleteCommunicationTemplate.Handler>, + ComAtprotoAdminDeleteCommunicationTemplate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.deleteCommunicationTemplate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + disableAccountInvites( cfg: ConfigOf< AV, @@ -332,6 +358,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + listCommunicationTemplates( + cfg: ConfigOf< + AV, + ComAtprotoAdminListCommunicationTemplates.Handler>, + ComAtprotoAdminListCommunicationTemplates.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.listCommunicationTemplates' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + queryModerationEvents( cfg: ConfigOf< AV, @@ -398,6 +435,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + updateCommunicationTemplate( + cfg: ConfigOf< + AV, + ComAtprotoAdminUpdateCommunicationTemplate.Handler>, + ComAtprotoAdminUpdateCommunicationTemplate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.updateCommunicationTemplate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + updateSubjectStatus( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 258d297c69e..09a586338ea 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -4,6 +4,51 @@ import { LexiconDoc, Lexicons } from '@atproto/lexicon' export const schemaDict = { + ComAtprotoAdminCreateCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.createCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: + 'Administrative action to create a new, re-usable communication (email for now) template.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject', 'contentMarkdown', 'name'], + properties: { + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + createdBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is creating the template.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, ComAtprotoAdminDefs: { lexicon: 1, id: 'com.atproto.admin.defs', @@ -850,6 +895,52 @@ export const schemaDict = { }, }, }, + communicationTemplateView: { + type: 'object', + required: [ + 'id', + 'name', + 'contentMarkdown', + 'disabled', + 'lastUpdatedBy', + 'createdAt', + 'updatedAt', + ], + properties: { + id: { + type: 'string', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + subject: { + type: 'string', + description: + 'Content of the template, can contain markdown and variable placeholders.', + }, + contentMarkdown: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + disabled: { + type: 'boolean', + }, + lastUpdatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who last updated the template.', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + updatedAt: { + type: 'string', + format: 'datetime', + }, + }, + }, }, }, ComAtprotoAdminDeleteAccount: { @@ -875,6 +966,28 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.deleteCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: 'Delete a communication template.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -1282,6 +1395,32 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminListCommunicationTemplates: { + lexicon: 1, + id: 'com.atproto.admin.listCommunicationTemplates', + defs: { + main: { + type: 'query', + description: 'Get list of all communication templates.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['communicationTemplates'], + properties: { + communicationTemplates: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminQueryModerationEvents: { lexicon: 1, id: 'com.atproto.admin.queryModerationEvents', @@ -1619,6 +1758,58 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminUpdateCommunicationTemplate: { + lexicon: 1, + id: 'com.atproto.admin.updateCommunicationTemplate', + defs: { + main: { + type: 'procedure', + description: + 'Administrative action to update an existing communication template. Allows passing partial fields to patch specific fields only.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['id'], + properties: { + id: { + type: 'string', + description: 'ID of the template to be updated.', + }, + name: { + type: 'string', + description: 'Name of the template.', + }, + contentMarkdown: { + type: 'string', + description: + 'Content of the template, markdown supported, can contain variable placeholders.', + }, + subject: { + type: 'string', + description: 'Subject of the message, used in emails.', + }, + updatedBy: { + type: 'string', + format: 'did', + description: 'DID of the user who is updating the template.', + }, + disabled: { + type: 'boolean', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#communicationTemplateView', + }, + }, + }, + }, + }, ComAtprotoAdminUpdateSubjectStatus: { lexicon: 1, id: 'com.atproto.admin.updateSubjectStatus', @@ -7912,8 +8103,12 @@ export const schemaDict = { export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { + ComAtprotoAdminCreateCommunicationTemplate: + 'com.atproto.admin.createCommunicationTemplate', ComAtprotoAdminDefs: 'com.atproto.admin.defs', ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', + ComAtprotoAdminDeleteCommunicationTemplate: + 'com.atproto.admin.deleteCommunicationTemplate', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', @@ -7926,6 +8121,8 @@ export const ids = { ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', ComAtprotoAdminGetRepo: 'com.atproto.admin.getRepo', ComAtprotoAdminGetSubjectStatus: 'com.atproto.admin.getSubjectStatus', + ComAtprotoAdminListCommunicationTemplates: + 'com.atproto.admin.listCommunicationTemplates', ComAtprotoAdminQueryModerationEvents: 'com.atproto.admin.queryModerationEvents', ComAtprotoAdminQueryModerationStatuses: @@ -7934,6 +8131,8 @@ export const ids = { ComAtprotoAdminSendEmail: 'com.atproto.admin.sendEmail', ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail', ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle', + ComAtprotoAdminUpdateCommunicationTemplate: + 'com.atproto.admin.updateCommunicationTemplate', ComAtprotoAdminUpdateSubjectStatus: 'com.atproto.admin.updateSubjectStatus', ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle', ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts b/packages/pds/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts new file mode 100644 index 00000000000..d42a8f2ef1d --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/createCommunicationTemplate.ts @@ -0,0 +1,54 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + /** Name of the template. */ + name: string + /** Content of the template, markdown supported, can contain variable placeholders. */ + contentMarkdown: string + /** Subject of the message, used in emails. */ + subject: string + /** DID of the user who is creating the template. */ + createdBy?: string + [k: string]: unknown +} + +export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView + +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 +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index 8236f848fa0..41be2ad96e7 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -717,3 +717,38 @@ export function isModEventEmail(v: unknown): v is ModEventEmail { export function validateModEventEmail(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventEmail', v) } + +export interface CommunicationTemplateView { + id: string + /** Name of the template. */ + name: string + /** Content of the template, can contain markdown and variable placeholders. */ + subject?: string + /** Subject of the message, used in emails. */ + contentMarkdown: string + disabled: boolean + /** DID of the user who last updated the template. */ + lastUpdatedBy: string + createdAt: string + updatedAt: string + [k: string]: unknown +} + +export function isCommunicationTemplateView( + v: unknown, +): v is CommunicationTemplateView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#communicationTemplateView' + ) +} + +export function validateCommunicationTemplateView( + v: unknown, +): ValidationResult { + return lexicons.validate( + 'com.atproto.admin.defs#communicationTemplateView', + v, + ) +} diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts b/packages/pds/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts new file mode 100644 index 00000000000..4bc6ec86fe4 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/deleteCommunicationTemplate.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + id: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +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/admin/listCommunicationTemplates.ts b/packages/pds/src/lexicon/types/com/atproto/admin/listCommunicationTemplates.ts new file mode 100644 index 00000000000..cb479533d39 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/listCommunicationTemplates.ts @@ -0,0 +1,44 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + communicationTemplates: ComAtprotoAdminDefs.CommunicationTemplateView[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts b/packages/pds/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts new file mode 100644 index 00000000000..5dc5cecda4a --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/updateCommunicationTemplate.ts @@ -0,0 +1,57 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams {} + +export interface InputSchema { + /** ID of the template to be updated. */ + id: string + /** Name of the template. */ + name?: string + /** Content of the template, markdown supported, can contain variable placeholders. */ + contentMarkdown?: string + /** Subject of the message, used in emails. */ + subject?: string + /** DID of the user who is updating the template. */ + updatedBy?: string + disabled?: boolean + [k: string]: unknown +} + +export type OutputSchema = ComAtprotoAdminDefs.CommunicationTemplateView + +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 +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput From a4349a37abaf4cf01dcd01bff2aa9b239161f955 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 19 Jan 2024 17:05:23 -0500 Subject: [PATCH 057/135] build --- .github/workflows/build-and-push-pds-aws.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 097f782d88e..8febb8ce234 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - multi-pds-auth env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From 0748b9427d02e447670d897b09a438bbe4820e91 Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 22 Jan 2024 21:28:23 -0600 Subject: [PATCH 058/135] build branch --- .github/workflows/build-and-push-pds-aws.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 097f782d88e..7589f56769a 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - entryway-twilio env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From 6884f03c6070592ee99b56268203a9a55ff905d4 Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 22 Jan 2024 21:34:19 -0600 Subject: [PATCH 059/135] update env name --- packages/pds/src/config/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 1dd16a2c2a0..1d332f9b8ff 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -54,7 +54,7 @@ export const readEnv = (): ServerEnvironment => { accountsPerPhoneNumber: envInt('PDS_ACCOUNTS_PER_PHONE_NUMBER'), twilioAccountSid: envStr('PDS_TWILIO_ACCOUNT_SID'), twilioAuthToken: envStr('PDS_TWILIO_AUTH_TOKEN'), - twilioServiceSid: envStr('TWILIO_SERVICE_SID'), + twilioServiceSid: envStr('PDS_TWILIO_SERVICE_SID'), // email emailSmtpUrl: envStr('PDS_EMAIL_SMTP_URL'), From 43071d16ec8da16ab04f4b365ea72054bdb8f395 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 11:25:50 -0600 Subject: [PATCH 060/135] schemas --- .../com/atproto/server/createAccount.json | 3 ++- .../atproto/temp/checkSignupAvailability.json | 27 +++++++++++++++++++ .../atproto/temp/sendSignupQueueEmails.json | 17 ++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 lexicons/com/atproto/temp/checkSignupAvailability.json create mode 100644 lexicons/com/atproto/temp/sendSignupQueueEmails.json diff --git a/lexicons/com/atproto/server/createAccount.json b/lexicons/com/atproto/server/createAccount.json index d1456e095ae..f998c9846a2 100644 --- a/lexicons/com/atproto/server/createAccount.json +++ b/lexicons/com/atproto/server/createAccount.json @@ -44,7 +44,8 @@ { "name": "HandleNotAvailable" }, { "name": "UnsupportedDomain" }, { "name": "UnresolvableDid" }, - { "name": "IncompatibleDidDoc" } + { "name": "IncompatibleDidDoc" }, + { "name": "SignupCapacity" } ] } } diff --git a/lexicons/com/atproto/temp/checkSignupAvailability.json b/lexicons/com/atproto/temp/checkSignupAvailability.json new file mode 100644 index 00000000000..b7ec92fcee4 --- /dev/null +++ b/lexicons/com/atproto/temp/checkSignupAvailability.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.temp.checkSignupAvailability", + "defs": { + "main": { + "type": "query", + "description": "Check if the service has availability for new accounts and submit email for waitlist queue.", + "parameters": { + "type": "params", + "required": ["email"], + "properties": { + "email": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["hasAvailability"], + "properties": { + "hasAvailability": { "type": "boolean" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/temp/sendSignupQueueEmails.json b/lexicons/com/atproto/temp/sendSignupQueueEmails.json new file mode 100644 index 00000000000..9e8092fa7f9 --- /dev/null +++ b/lexicons/com/atproto/temp/sendSignupQueueEmails.json @@ -0,0 +1,17 @@ +{ + "lexicon": 1, + "id": "com.atproto.temp.sendSignupQueueEmails", + "defs": { + "main": { + "type": "query", + "description": "Fetch all labels from a labeler created after a certain date.", + "parameters": { + "type": "params", + "required": ["count"], + "properties": { + "count": { "type": "integer" } + } + } + } + } +} From 413e24f774f27ccf797ffacbad13699df2809f72 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 12:00:21 -0600 Subject: [PATCH 061/135] gen code --- packages/api/src/client/index.ts | 128 +++++++++++------- packages/api/src/client/lexicons.ts | 58 ++++++++ .../types/com/atproto/server/createAccount.ts | 7 + .../atproto/temp/checkSignupAvailability.ts | 35 +++++ .../com/atproto/temp/sendSignupQueueEmails.ts | 29 ++++ packages/bsky/src/lexicon/index.ts | 128 +++++++++++------- packages/bsky/src/lexicon/lexicons.ts | 58 ++++++++ .../types/com/atproto/server/createAccount.ts | 1 + .../atproto/temp/checkSignupAvailability.ts | 45 ++++++ .../com/atproto/temp/sendSignupQueueEmails.ts | 33 +++++ packages/pds/src/lexicon/index.ts | 128 +++++++++++------- packages/pds/src/lexicon/lexicons.ts | 58 ++++++++ .../types/com/atproto/server/createAccount.ts | 1 + .../atproto/temp/checkSignupAvailability.ts | 45 ++++++ .../com/atproto/temp/sendSignupQueueEmails.ts | 33 +++++ 15 files changed, 634 insertions(+), 153 deletions(-) create mode 100644 packages/api/src/client/types/com/atproto/temp/checkSignupAvailability.ts create mode 100644 packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 2bff31ccc27..a6f5da1b80d 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -81,10 +81,12 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' +import * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' 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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -226,10 +228,12 @@ export * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' export * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' export * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' export * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' +export * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' 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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -348,39 +352,39 @@ export class AtpServiceClient { export class ComNS { _service: AtpServiceClient - atproto: AtprotoNS + atproto: ComAtprotoNS constructor(service: AtpServiceClient) { this._service = service - this.atproto = new AtprotoNS(service) + this.atproto = new ComAtprotoNS(service) } } -export class AtprotoNS { +export class ComAtprotoNS { _service: AtpServiceClient - admin: AdminNS - identity: IdentityNS - label: LabelNS - moderation: ModerationNS - repo: RepoNS - server: ServerNS - sync: SyncNS - temp: TempNS + admin: ComAtprotoAdminNS + identity: ComAtprotoIdentityNS + label: ComAtprotoLabelNS + moderation: ComAtprotoModerationNS + repo: ComAtprotoRepoNS + server: ComAtprotoServerNS + sync: ComAtprotoSyncNS + temp: ComAtprotoTempNS constructor(service: AtpServiceClient) { this._service = service - this.admin = new AdminNS(service) - this.identity = new IdentityNS(service) - this.label = new LabelNS(service) - this.moderation = new ModerationNS(service) - this.repo = new RepoNS(service) - this.server = new ServerNS(service) - this.sync = new SyncNS(service) - this.temp = new TempNS(service) + this.admin = new ComAtprotoAdminNS(service) + this.identity = new ComAtprotoIdentityNS(service) + this.label = new ComAtprotoLabelNS(service) + this.moderation = new ComAtprotoModerationNS(service) + this.repo = new ComAtprotoRepoNS(service) + this.server = new ComAtprotoServerNS(service) + this.sync = new ComAtprotoSyncNS(service) + this.temp = new ComAtprotoTempNS(service) } } -export class AdminNS { +export class ComAtprotoAdminNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -666,7 +670,7 @@ export class AdminNS { } } -export class IdentityNS { +export class ComAtprotoIdentityNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -696,7 +700,7 @@ export class IdentityNS { } } -export class LabelNS { +export class ComAtprotoLabelNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -715,7 +719,7 @@ export class LabelNS { } } -export class ModerationNS { +export class ComAtprotoModerationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -734,7 +738,7 @@ export class ModerationNS { } } -export class RepoNS { +export class ComAtprotoRepoNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -830,7 +834,7 @@ export class RepoNS { } } -export class ServerNS { +export class ComAtprotoServerNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1069,7 +1073,7 @@ export class ServerNS { } } -export class SyncNS { +export class ComAtprotoSyncNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1198,13 +1202,24 @@ export class SyncNS { } } -export class TempNS { +export class ComAtprotoTempNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { this._service = service } + checkSignupAvailability( + params?: ComAtprotoTempCheckSignupAvailability.QueryParams, + opts?: ComAtprotoTempCheckSignupAvailability.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.temp.checkSignupAvailability', params, undefined, opts) + .catch((e) => { + throw ComAtprotoTempCheckSignupAvailability.toKnownErr(e) + }) + } + fetchLabels( params?: ComAtprotoTempFetchLabels.QueryParams, opts?: ComAtprotoTempFetchLabels.CallOptions, @@ -1249,6 +1264,17 @@ export class TempNS { }) } + sendSignupQueueEmails( + params?: ComAtprotoTempSendSignupQueueEmails.QueryParams, + opts?: ComAtprotoTempSendSignupQueueEmails.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.temp.sendSignupQueueEmails', params, undefined, opts) + .catch((e) => { + throw ComAtprotoTempSendSignupQueueEmails.toKnownErr(e) + }) + } + transferAccount( data?: ComAtprotoTempTransferAccount.InputSchema, opts?: ComAtprotoTempTransferAccount.CallOptions, @@ -1263,37 +1289,37 @@ export class TempNS { export class AppNS { _service: AtpServiceClient - bsky: BskyNS + bsky: AppBskyNS constructor(service: AtpServiceClient) { this._service = service - this.bsky = new BskyNS(service) + this.bsky = new AppBskyNS(service) } } -export class BskyNS { +export class AppBskyNS { _service: AtpServiceClient - actor: ActorNS - embed: EmbedNS - feed: FeedNS - graph: GraphNS - notification: NotificationNS - richtext: RichtextNS - unspecced: UnspeccedNS + actor: AppBskyActorNS + embed: AppBskyEmbedNS + feed: AppBskyFeedNS + graph: AppBskyGraphNS + notification: AppBskyNotificationNS + richtext: AppBskyRichtextNS + unspecced: AppBskyUnspeccedNS constructor(service: AtpServiceClient) { this._service = service - this.actor = new ActorNS(service) - this.embed = new EmbedNS(service) - this.feed = new FeedNS(service) - this.graph = new GraphNS(service) - this.notification = new NotificationNS(service) - this.richtext = new RichtextNS(service) - this.unspecced = new UnspeccedNS(service) + this.actor = new AppBskyActorNS(service) + this.embed = new AppBskyEmbedNS(service) + this.feed = new AppBskyFeedNS(service) + this.graph = new AppBskyGraphNS(service) + this.notification = new AppBskyNotificationNS(service) + this.richtext = new AppBskyRichtextNS(service) + this.unspecced = new AppBskyUnspeccedNS(service) } } -export class ActorNS { +export class AppBskyActorNS { _service: AtpServiceClient profile: ProfileRecord @@ -1441,7 +1467,7 @@ export class ProfileRecord { } } -export class EmbedNS { +export class AppBskyEmbedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1449,7 +1475,7 @@ export class EmbedNS { } } -export class FeedNS { +export class AppBskyFeedNS { _service: AtpServiceClient generator: GeneratorRecord like: LikeRecord @@ -1952,7 +1978,7 @@ export class ThreadgateRecord { } } -export class GraphNS { +export class AppBskyGraphNS { _service: AtpServiceClient block: BlockRecord follow: FollowRecord @@ -2427,7 +2453,7 @@ export class ListitemRecord { } } -export class NotificationNS { +export class AppBskyNotificationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2479,7 +2505,7 @@ export class NotificationNS { } } -export class RichtextNS { +export class AppBskyRichtextNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2487,7 +2513,7 @@ export class RichtextNS { } } -export class UnspeccedNS { +export class AppBskyUnspeccedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 95fd1bcf549..ea33e3d8666 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2921,6 +2921,9 @@ export const schemaDict = { { name: 'IncompatibleDidDoc', }, + { + name: 'SignupCapacity', + }, ], }, }, @@ -4246,6 +4249,38 @@ export const schemaDict = { }, }, }, + ComAtprotoTempCheckSignupAvailability: { + lexicon: 1, + id: 'com.atproto.temp.checkSignupAvailability', + defs: { + main: { + type: 'query', + description: + 'Check if the service has availability for new accounts and submit email for waitlist queue.', + parameters: { + type: 'params', + required: ['email'], + properties: { + email: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['hasAvailability'], + properties: { + hasAvailability: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoTempFetchLabels: { lexicon: 1, id: 'com.atproto.temp.fetchLabels', @@ -4363,6 +4398,26 @@ export const schemaDict = { }, }, }, + ComAtprotoTempSendSignupQueueEmails: { + lexicon: 1, + id: 'com.atproto.temp.sendSignupQueueEmails', + defs: { + main: { + type: 'query', + description: + 'Fetch all labels from a labeler created after a certain date.', + parameters: { + type: 'params', + required: ['count'], + properties: { + count: { + type: 'integer', + }, + }, + }, + }, + }, + }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -8220,11 +8275,14 @@ export const ids = { ComAtprotoSyncNotifyOfUpdate: 'com.atproto.sync.notifyOfUpdate', ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', + ComAtprotoTempCheckSignupAvailability: + 'com.atproto.temp.checkSignupAvailability', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', + ComAtprotoTempSendSignupQueueEmails: 'com.atproto.temp.sendSignupQueueEmails', ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', diff --git a/packages/api/src/client/types/com/atproto/server/createAccount.ts b/packages/api/src/client/types/com/atproto/server/createAccount.ts index b62adf97cb1..b702623385a 100644 --- a/packages/api/src/client/types/com/atproto/server/createAccount.ts +++ b/packages/api/src/client/types/com/atproto/server/createAccount.ts @@ -85,6 +85,12 @@ export class IncompatibleDidDocError extends XRPCError { } } +export class SignupCapacityError 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) @@ -94,6 +100,7 @@ export function toKnownErr(e: any) { if (e.error === 'UnsupportedDomain') return new UnsupportedDomainError(e) if (e.error === 'UnresolvableDid') return new UnresolvableDidError(e) if (e.error === 'IncompatibleDidDoc') return new IncompatibleDidDocError(e) + if (e.error === 'SignupCapacity') return new SignupCapacityError(e) } return e } diff --git a/packages/api/src/client/types/com/atproto/temp/checkSignupAvailability.ts b/packages/api/src/client/types/com/atproto/temp/checkSignupAvailability.ts new file mode 100644 index 00000000000..39baf9b3a05 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/temp/checkSignupAvailability.ts @@ -0,0 +1,35 @@ +/** + * 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 { + email: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + hasAvailability: boolean + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts b/packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts new file mode 100644 index 00000000000..9e5f7073088 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts @@ -0,0 +1,29 @@ +/** + * 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 { + count: number +} + +export type InputSchema = undefined + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index eb9dfd23f2d..1c9caa30336 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -78,10 +78,12 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' +import * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' 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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -166,39 +168,39 @@ export class Server { export class ComNS { _server: Server - atproto: AtprotoNS + atproto: ComAtprotoNS constructor(server: Server) { this._server = server - this.atproto = new AtprotoNS(server) + this.atproto = new ComAtprotoNS(server) } } -export class AtprotoNS { +export class ComAtprotoNS { _server: Server - admin: AdminNS - identity: IdentityNS - label: LabelNS - moderation: ModerationNS - repo: RepoNS - server: ServerNS - sync: SyncNS - temp: TempNS + admin: ComAtprotoAdminNS + identity: ComAtprotoIdentityNS + label: ComAtprotoLabelNS + moderation: ComAtprotoModerationNS + repo: ComAtprotoRepoNS + server: ComAtprotoServerNS + sync: ComAtprotoSyncNS + temp: ComAtprotoTempNS constructor(server: Server) { this._server = server - this.admin = new AdminNS(server) - this.identity = new IdentityNS(server) - this.label = new LabelNS(server) - this.moderation = new ModerationNS(server) - this.repo = new RepoNS(server) - this.server = new ServerNS(server) - this.sync = new SyncNS(server) - this.temp = new TempNS(server) + this.admin = new ComAtprotoAdminNS(server) + this.identity = new ComAtprotoIdentityNS(server) + this.label = new ComAtprotoLabelNS(server) + this.moderation = new ComAtprotoModerationNS(server) + this.repo = new ComAtprotoRepoNS(server) + this.server = new ComAtprotoServerNS(server) + this.sync = new ComAtprotoSyncNS(server) + this.temp = new ComAtprotoTempNS(server) } } -export class AdminNS { +export class ComAtprotoAdminNS { _server: Server constructor(server: Server) { @@ -459,7 +461,7 @@ export class AdminNS { } } -export class IdentityNS { +export class ComAtprotoIdentityNS { _server: Server constructor(server: Server) { @@ -489,7 +491,7 @@ export class IdentityNS { } } -export class LabelNS { +export class ComAtprotoLabelNS { _server: Server constructor(server: Server) { @@ -519,7 +521,7 @@ export class LabelNS { } } -export class ModerationNS { +export class ComAtprotoModerationNS { _server: Server constructor(server: Server) { @@ -538,7 +540,7 @@ export class ModerationNS { } } -export class RepoNS { +export class ComAtprotoRepoNS { _server: Server constructor(server: Server) { @@ -634,7 +636,7 @@ export class RepoNS { } } -export class ServerNS { +export class ComAtprotoServerNS { _server: Server constructor(server: Server) { @@ -873,7 +875,7 @@ export class ServerNS { } } -export class SyncNS { +export class ComAtprotoSyncNS { _server: Server constructor(server: Server) { @@ -1013,13 +1015,24 @@ export class SyncNS { } } -export class TempNS { +export class ComAtprotoTempNS { _server: Server constructor(server: Server) { this._server = server } + checkSignupAvailability( + cfg: ConfigOf< + AV, + ComAtprotoTempCheckSignupAvailability.Handler>, + ComAtprotoTempCheckSignupAvailability.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.temp.checkSignupAvailability' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + fetchLabels( cfg: ConfigOf< AV, @@ -1064,6 +1077,17 @@ export class TempNS { return this._server.xrpc.method(nsid, cfg) } + sendSignupQueueEmails( + cfg: ConfigOf< + AV, + ComAtprotoTempSendSignupQueueEmails.Handler>, + ComAtprotoTempSendSignupQueueEmails.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.temp.sendSignupQueueEmails' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + transferAccount( cfg: ConfigOf< AV, @@ -1078,37 +1102,37 @@ export class TempNS { export class AppNS { _server: Server - bsky: BskyNS + bsky: AppBskyNS constructor(server: Server) { this._server = server - this.bsky = new BskyNS(server) + this.bsky = new AppBskyNS(server) } } -export class BskyNS { +export class AppBskyNS { _server: Server - actor: ActorNS - embed: EmbedNS - feed: FeedNS - graph: GraphNS - notification: NotificationNS - richtext: RichtextNS - unspecced: UnspeccedNS + actor: AppBskyActorNS + embed: AppBskyEmbedNS + feed: AppBskyFeedNS + graph: AppBskyGraphNS + notification: AppBskyNotificationNS + richtext: AppBskyRichtextNS + unspecced: AppBskyUnspeccedNS constructor(server: Server) { this._server = server - this.actor = new ActorNS(server) - this.embed = new EmbedNS(server) - this.feed = new FeedNS(server) - this.graph = new GraphNS(server) - this.notification = new NotificationNS(server) - this.richtext = new RichtextNS(server) - this.unspecced = new UnspeccedNS(server) + this.actor = new AppBskyActorNS(server) + this.embed = new AppBskyEmbedNS(server) + this.feed = new AppBskyFeedNS(server) + this.graph = new AppBskyGraphNS(server) + this.notification = new AppBskyNotificationNS(server) + this.richtext = new AppBskyRichtextNS(server) + this.unspecced = new AppBskyUnspeccedNS(server) } } -export class ActorNS { +export class AppBskyActorNS { _server: Server constructor(server: Server) { @@ -1193,7 +1217,7 @@ export class ActorNS { } } -export class EmbedNS { +export class AppBskyEmbedNS { _server: Server constructor(server: Server) { @@ -1201,7 +1225,7 @@ export class EmbedNS { } } -export class FeedNS { +export class AppBskyFeedNS { _server: Server constructor(server: Server) { @@ -1385,7 +1409,7 @@ export class FeedNS { } } -export class GraphNS { +export class AppBskyGraphNS { _server: Server constructor(server: Server) { @@ -1536,7 +1560,7 @@ export class GraphNS { } } -export class NotificationNS { +export class AppBskyNotificationNS { _server: Server constructor(server: Server) { @@ -1588,7 +1612,7 @@ export class NotificationNS { } } -export class RichtextNS { +export class AppBskyRichtextNS { _server: Server constructor(server: Server) { @@ -1596,7 +1620,7 @@ export class RichtextNS { } } -export class UnspeccedNS { +export class AppBskyUnspeccedNS { _server: Server constructor(server: Server) { @@ -1670,11 +1694,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 95fd1bcf549..ea33e3d8666 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2921,6 +2921,9 @@ export const schemaDict = { { name: 'IncompatibleDidDoc', }, + { + name: 'SignupCapacity', + }, ], }, }, @@ -4246,6 +4249,38 @@ export const schemaDict = { }, }, }, + ComAtprotoTempCheckSignupAvailability: { + lexicon: 1, + id: 'com.atproto.temp.checkSignupAvailability', + defs: { + main: { + type: 'query', + description: + 'Check if the service has availability for new accounts and submit email for waitlist queue.', + parameters: { + type: 'params', + required: ['email'], + properties: { + email: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['hasAvailability'], + properties: { + hasAvailability: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoTempFetchLabels: { lexicon: 1, id: 'com.atproto.temp.fetchLabels', @@ -4363,6 +4398,26 @@ export const schemaDict = { }, }, }, + ComAtprotoTempSendSignupQueueEmails: { + lexicon: 1, + id: 'com.atproto.temp.sendSignupQueueEmails', + defs: { + main: { + type: 'query', + description: + 'Fetch all labels from a labeler created after a certain date.', + parameters: { + type: 'params', + required: ['count'], + properties: { + count: { + type: 'integer', + }, + }, + }, + }, + }, + }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -8220,11 +8275,14 @@ export const ids = { ComAtprotoSyncNotifyOfUpdate: 'com.atproto.sync.notifyOfUpdate', ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', + ComAtprotoTempCheckSignupAvailability: + 'com.atproto.temp.checkSignupAvailability', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', + ComAtprotoTempSendSignupQueueEmails: 'com.atproto.temp.sendSignupQueueEmails', ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts index bbf2c009bf5..4784a9f0df3 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts @@ -54,6 +54,7 @@ export interface HandlerError { | 'UnsupportedDomain' | 'UnresolvableDid' | 'IncompatibleDidDoc' + | 'SignupCapacity' } export type HandlerOutput = HandlerError | HandlerSuccess diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts new file mode 100644 index 00000000000..cd462bfee7d --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts @@ -0,0 +1,45 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams { + email: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + hasAvailability: boolean + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts new file mode 100644 index 00000000000..1cb21d8baee --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts @@ -0,0 +1,33 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams { + count: number +} + +export type InputSchema = undefined +export type HandlerInput = undefined + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index eb9dfd23f2d..1c9caa30336 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -78,10 +78,12 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' +import * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' 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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -166,39 +168,39 @@ export class Server { export class ComNS { _server: Server - atproto: AtprotoNS + atproto: ComAtprotoNS constructor(server: Server) { this._server = server - this.atproto = new AtprotoNS(server) + this.atproto = new ComAtprotoNS(server) } } -export class AtprotoNS { +export class ComAtprotoNS { _server: Server - admin: AdminNS - identity: IdentityNS - label: LabelNS - moderation: ModerationNS - repo: RepoNS - server: ServerNS - sync: SyncNS - temp: TempNS + admin: ComAtprotoAdminNS + identity: ComAtprotoIdentityNS + label: ComAtprotoLabelNS + moderation: ComAtprotoModerationNS + repo: ComAtprotoRepoNS + server: ComAtprotoServerNS + sync: ComAtprotoSyncNS + temp: ComAtprotoTempNS constructor(server: Server) { this._server = server - this.admin = new AdminNS(server) - this.identity = new IdentityNS(server) - this.label = new LabelNS(server) - this.moderation = new ModerationNS(server) - this.repo = new RepoNS(server) - this.server = new ServerNS(server) - this.sync = new SyncNS(server) - this.temp = new TempNS(server) + this.admin = new ComAtprotoAdminNS(server) + this.identity = new ComAtprotoIdentityNS(server) + this.label = new ComAtprotoLabelNS(server) + this.moderation = new ComAtprotoModerationNS(server) + this.repo = new ComAtprotoRepoNS(server) + this.server = new ComAtprotoServerNS(server) + this.sync = new ComAtprotoSyncNS(server) + this.temp = new ComAtprotoTempNS(server) } } -export class AdminNS { +export class ComAtprotoAdminNS { _server: Server constructor(server: Server) { @@ -459,7 +461,7 @@ export class AdminNS { } } -export class IdentityNS { +export class ComAtprotoIdentityNS { _server: Server constructor(server: Server) { @@ -489,7 +491,7 @@ export class IdentityNS { } } -export class LabelNS { +export class ComAtprotoLabelNS { _server: Server constructor(server: Server) { @@ -519,7 +521,7 @@ export class LabelNS { } } -export class ModerationNS { +export class ComAtprotoModerationNS { _server: Server constructor(server: Server) { @@ -538,7 +540,7 @@ export class ModerationNS { } } -export class RepoNS { +export class ComAtprotoRepoNS { _server: Server constructor(server: Server) { @@ -634,7 +636,7 @@ export class RepoNS { } } -export class ServerNS { +export class ComAtprotoServerNS { _server: Server constructor(server: Server) { @@ -873,7 +875,7 @@ export class ServerNS { } } -export class SyncNS { +export class ComAtprotoSyncNS { _server: Server constructor(server: Server) { @@ -1013,13 +1015,24 @@ export class SyncNS { } } -export class TempNS { +export class ComAtprotoTempNS { _server: Server constructor(server: Server) { this._server = server } + checkSignupAvailability( + cfg: ConfigOf< + AV, + ComAtprotoTempCheckSignupAvailability.Handler>, + ComAtprotoTempCheckSignupAvailability.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.temp.checkSignupAvailability' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + fetchLabels( cfg: ConfigOf< AV, @@ -1064,6 +1077,17 @@ export class TempNS { return this._server.xrpc.method(nsid, cfg) } + sendSignupQueueEmails( + cfg: ConfigOf< + AV, + ComAtprotoTempSendSignupQueueEmails.Handler>, + ComAtprotoTempSendSignupQueueEmails.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.temp.sendSignupQueueEmails' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + transferAccount( cfg: ConfigOf< AV, @@ -1078,37 +1102,37 @@ export class TempNS { export class AppNS { _server: Server - bsky: BskyNS + bsky: AppBskyNS constructor(server: Server) { this._server = server - this.bsky = new BskyNS(server) + this.bsky = new AppBskyNS(server) } } -export class BskyNS { +export class AppBskyNS { _server: Server - actor: ActorNS - embed: EmbedNS - feed: FeedNS - graph: GraphNS - notification: NotificationNS - richtext: RichtextNS - unspecced: UnspeccedNS + actor: AppBskyActorNS + embed: AppBskyEmbedNS + feed: AppBskyFeedNS + graph: AppBskyGraphNS + notification: AppBskyNotificationNS + richtext: AppBskyRichtextNS + unspecced: AppBskyUnspeccedNS constructor(server: Server) { this._server = server - this.actor = new ActorNS(server) - this.embed = new EmbedNS(server) - this.feed = new FeedNS(server) - this.graph = new GraphNS(server) - this.notification = new NotificationNS(server) - this.richtext = new RichtextNS(server) - this.unspecced = new UnspeccedNS(server) + this.actor = new AppBskyActorNS(server) + this.embed = new AppBskyEmbedNS(server) + this.feed = new AppBskyFeedNS(server) + this.graph = new AppBskyGraphNS(server) + this.notification = new AppBskyNotificationNS(server) + this.richtext = new AppBskyRichtextNS(server) + this.unspecced = new AppBskyUnspeccedNS(server) } } -export class ActorNS { +export class AppBskyActorNS { _server: Server constructor(server: Server) { @@ -1193,7 +1217,7 @@ export class ActorNS { } } -export class EmbedNS { +export class AppBskyEmbedNS { _server: Server constructor(server: Server) { @@ -1201,7 +1225,7 @@ export class EmbedNS { } } -export class FeedNS { +export class AppBskyFeedNS { _server: Server constructor(server: Server) { @@ -1385,7 +1409,7 @@ export class FeedNS { } } -export class GraphNS { +export class AppBskyGraphNS { _server: Server constructor(server: Server) { @@ -1536,7 +1560,7 @@ export class GraphNS { } } -export class NotificationNS { +export class AppBskyNotificationNS { _server: Server constructor(server: Server) { @@ -1588,7 +1612,7 @@ export class NotificationNS { } } -export class RichtextNS { +export class AppBskyRichtextNS { _server: Server constructor(server: Server) { @@ -1596,7 +1620,7 @@ export class RichtextNS { } } -export class UnspeccedNS { +export class AppBskyUnspeccedNS { _server: Server constructor(server: Server) { @@ -1670,11 +1694,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 95fd1bcf549..ea33e3d8666 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2921,6 +2921,9 @@ export const schemaDict = { { name: 'IncompatibleDidDoc', }, + { + name: 'SignupCapacity', + }, ], }, }, @@ -4246,6 +4249,38 @@ export const schemaDict = { }, }, }, + ComAtprotoTempCheckSignupAvailability: { + lexicon: 1, + id: 'com.atproto.temp.checkSignupAvailability', + defs: { + main: { + type: 'query', + description: + 'Check if the service has availability for new accounts and submit email for waitlist queue.', + parameters: { + type: 'params', + required: ['email'], + properties: { + email: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['hasAvailability'], + properties: { + hasAvailability: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoTempFetchLabels: { lexicon: 1, id: 'com.atproto.temp.fetchLabels', @@ -4363,6 +4398,26 @@ export const schemaDict = { }, }, }, + ComAtprotoTempSendSignupQueueEmails: { + lexicon: 1, + id: 'com.atproto.temp.sendSignupQueueEmails', + defs: { + main: { + type: 'query', + description: + 'Fetch all labels from a labeler created after a certain date.', + parameters: { + type: 'params', + required: ['count'], + properties: { + count: { + type: 'integer', + }, + }, + }, + }, + }, + }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -8220,11 +8275,14 @@ export const ids = { ComAtprotoSyncNotifyOfUpdate: 'com.atproto.sync.notifyOfUpdate', ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', + ComAtprotoTempCheckSignupAvailability: + 'com.atproto.temp.checkSignupAvailability', ComAtprotoTempFetchLabels: 'com.atproto.temp.fetchLabels', ComAtprotoTempImportRepo: 'com.atproto.temp.importRepo', ComAtprotoTempPushBlob: 'com.atproto.temp.pushBlob', ComAtprotoTempRequestPhoneVerification: 'com.atproto.temp.requestPhoneVerification', + ComAtprotoTempSendSignupQueueEmails: 'com.atproto.temp.sendSignupQueueEmails', ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts index bbf2c009bf5..4784a9f0df3 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts @@ -54,6 +54,7 @@ export interface HandlerError { | 'UnsupportedDomain' | 'UnresolvableDid' | 'IncompatibleDidDoc' + | 'SignupCapacity' } export type HandlerOutput = HandlerError | HandlerSuccess diff --git a/packages/pds/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts b/packages/pds/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts new file mode 100644 index 00000000000..cd462bfee7d --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts @@ -0,0 +1,45 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams { + email: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + hasAvailability: boolean + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts b/packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts new file mode 100644 index 00000000000..1cb21d8baee --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts @@ -0,0 +1,33 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams { + count: number +} + +export type InputSchema = undefined +export type HandlerInput = undefined + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput From e8495088035a7caed389751d5a701c732a80dba2 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 13:05:30 -0600 Subject: [PATCH 062/135] sketching out signup limiter --- .../api/com/atproto/server/createAccount.ts | 8 ++++ .../atproto/temp/checkSignupAvailability.ts | 44 +++++++++++++++++++ .../pds/src/api/com/atproto/temp/index.ts | 4 ++ .../com/atproto/temp/sendSignupQueueEmails.ts | 12 +++++ packages/pds/src/context.ts | 7 +++ packages/pds/src/db/database-schema.ts | 4 +- packages/pds/src/db/tables/queued-email.ts | 11 +++++ packages/pds/src/signup-limiter.ts | 9 ++++ 8 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts create mode 100644 packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts create mode 100644 packages/pds/src/db/tables/queued-email.ts create mode 100644 packages/pds/src/signup-limiter.ts diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 5786ff92868..fa86d4ede0e 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -25,6 +25,14 @@ export default function (server: Server, ctx: AppContext) { points: 100, }, handler: async ({ input, req }) => { + const hasAvailability = ctx.signupLimiter.hasAvailability() + if (!hasAvailability) { + throw new InvalidRequestError( + 'Service at signup capacity, please check back later.', + 'SignupCapacity', + ) + } + const { did, handle, diff --git a/packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts b/packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts new file mode 100644 index 00000000000..56d5d04928e --- /dev/null +++ b/packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts @@ -0,0 +1,44 @@ +import disposable from 'disposable-email' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.temp.checkSignupAvailability({ + handler: async ({ params }) => { + const email = params.email.toLowerCase() + if (!disposable.validate(email)) { + throw new InvalidRequestError( + 'This email address is not supported, please use a different email.', + ) + } + + const alreadyExists = await ctx.db.db + .selectFrom('user_account') + .selectAll() + .where('email', '=', email) + .executeTakeFirst() + if (alreadyExists) { + throw new InvalidRequestError(`Email already taken: ${email}`) + } + + await ctx.db.db + .insertInto('queued_email') + .values({ + email, + registeredAt: new Date().toISOString(), + }) + .onConflict((oc) => oc.doNothing()) + .execute() + + const hasAvailability = ctx.signupLimiter.hasAvailability() + + return { + encoding: 'application/json', + body: { + hasAvailability, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/temp/index.ts b/packages/pds/src/api/com/atproto/temp/index.ts index db34f17bf29..90f2122101a 100644 --- a/packages/pds/src/api/com/atproto/temp/index.ts +++ b/packages/pds/src/api/com/atproto/temp/index.ts @@ -1,7 +1,11 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import requestPhoneVerification from './requestPhoneVerification' +import checkSignupAvailability from './checkSignupAvailability' +import sendSignupQueueEmails from './sendSignupQueueEmails' export default function (server: Server, ctx: AppContext) { requestPhoneVerification(server, ctx) + checkSignupAvailability(server, ctx) + sendSignupQueueEmails(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts b/packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts new file mode 100644 index 00000000000..f0f06aafd6b --- /dev/null +++ b/packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts @@ -0,0 +1,12 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { countAll } from '../../../../db/util' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.temp.sendSignupQueueEmails({ + handler: async () => { + throw new Error('unimplemented') + }, + }) +} diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 15ee684e28b..8311416b30e 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -23,6 +23,7 @@ import { RuntimeFlags } from './runtime-flags' import { PdsAgents } from './pds-agents' import { TwilioClient } from './twilio' import assert from 'assert' +import { SignupLimiter } from './signup-limiter' export type AppContextOptions = { db: Database @@ -46,6 +47,7 @@ export type AppContextOptions = { repoSigningKey: crypto.Keypair plcRotationKey: crypto.Keypair twilio?: TwilioClient + signupLimiter: SignupLimiter cfg: ServerConfig } @@ -71,6 +73,7 @@ export class AppContext { public repoSigningKey: crypto.Keypair public plcRotationKey: crypto.Keypair public twilio?: TwilioClient + public signupLimiter: SignupLimiter public cfg: ServerConfig constructor(opts: AppContextOptions) { @@ -95,6 +98,7 @@ export class AppContext { this.repoSigningKey = opts.repoSigningKey this.plcRotationKey = opts.plcRotationKey this.twilio = opts.twilio + this.signupLimiter = opts.signupLimiter this.cfg = opts.cfg } @@ -222,6 +226,8 @@ export class AppContext { }) } + const signupLimiter = new SignupLimiter() + const pdsAgents = new PdsAgents() return new AppContext({ @@ -246,6 +252,7 @@ export class AppContext { plcRotationKey, pdsAgents, twilio, + signupLimiter, cfg, ...(overrides ?? {}), }) diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 57599aa12d9..727a774953b 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -19,6 +19,7 @@ import * as repoSeq from './tables/repo-seq' import * as appMigration from './tables/app-migration' import * as runtimeFlag from './tables/runtime-flag' import * as phoneVerification from './tables/phone-verification' +import * as queuedEmail from './tables/queued-email' export type DatabaseSchemaType = appMigration.PartialDB & runtimeFlag.PartialDB & @@ -39,7 +40,8 @@ export type DatabaseSchemaType = appMigration.PartialDB & emailToken.PartialDB & moderation.PartialDB & repoSeq.PartialDB & - phoneVerification.PartialDB + phoneVerification.PartialDB & + queuedEmail.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/pds/src/db/tables/queued-email.ts b/packages/pds/src/db/tables/queued-email.ts new file mode 100644 index 00000000000..aff839df106 --- /dev/null +++ b/packages/pds/src/db/tables/queued-email.ts @@ -0,0 +1,11 @@ +export interface QueuedEmail { + email: string + registeredAt: string + lastEmailed: string | null +} + +export const tableName = 'queued_email' + +export type PartialDB = { + [tableName]: QueuedEmail +} diff --git a/packages/pds/src/signup-limiter.ts b/packages/pds/src/signup-limiter.ts new file mode 100644 index 00000000000..76ab318be61 --- /dev/null +++ b/packages/pds/src/signup-limiter.ts @@ -0,0 +1,9 @@ +import Database from './db' + +export class SignupLimiter { + constructor(public db: Database) {} + + hasAvailability(): boolean { + return true + } +} From d20afa1b0b50b7584c4c1db47f71188f6ff10c19 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 13:30:41 -0600 Subject: [PATCH 063/135] flesh out polling singup limiter --- packages/pds/src/context.ts | 2 +- packages/pds/src/index.ts | 2 + packages/pds/src/logger.ts | 1 + packages/pds/src/signup-limiter.ts | 98 +++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 8311416b30e..b5464a76866 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -226,7 +226,7 @@ export class AppContext { }) } - const signupLimiter = new SignupLimiter() + const signupLimiter = new SignupLimiter(db) const pdsAgents = new PdsAgents() diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 42544eba492..0d7e4fbbc4b 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -163,6 +163,7 @@ export class PDS { await this.ctx.sequencer.start() await this.ctx.db.startListeningToChannels() await this.ctx.runtimeFlags.start() + await this.ctx.signupLimiter.start() const server = this.app.listen(this.ctx.cfg.service.port) this.server = server this.server.keepAliveTimeout = 90000 @@ -173,6 +174,7 @@ export class PDS { async destroy(): Promise { await this.ctx.runtimeFlags.destroy() + await this.ctx.signupLimiter.destroy() await this.ctx.sequencerLeader?.destroy() await this.terminator?.terminate() await this.ctx.backgroundQueue.destroy() diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index e8b663b567f..15cf4741922 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -8,6 +8,7 @@ export const dbLogger = subsystemLogger('pds:db') export const readStickyLogger = subsystemLogger('pds:read-sticky') export const redisLogger = subsystemLogger('pds:redis') export const seqLogger = subsystemLogger('pds:sequencer') +export const limiterLogger = subsystemLogger('pds:limiter') export const mailerLogger = subsystemLogger('pds:mailer') export const labelerLogger = subsystemLogger('pds:labler') export const crawlerLogger = subsystemLogger('pds:crawler') diff --git a/packages/pds/src/signup-limiter.ts b/packages/pds/src/signup-limiter.ts index 76ab318be61..c53c350afca 100644 --- a/packages/pds/src/signup-limiter.ts +++ b/packages/pds/src/signup-limiter.ts @@ -1,9 +1,103 @@ +import { SECOND } from '@atproto/common' +import { limiterLogger as log } from './logger' import Database from './db' +import { countAll } from './db/util' + +type LimiterFlags = { + disableSignups: boolean + periodAllowance: number + periodMs: number +} + +type LimiterStatus = LimiterFlags & { + accountsInPeriod: number +} export class SignupLimiter { - constructor(public db: Database) {} + destroyed = false + promise: Promise = Promise.resolve() + timer: NodeJS.Timer | undefined + status: LimiterStatus + + constructor(private db: Database) {} hasAvailability(): boolean { - return true + if (this.status.disableSignups) return false + return this.status.accountsInPeriod < this.status.periodAllowance + } + + async start() { + this.poll() + await this.promise + } + + poll() { + if (this.destroyed) return + this.promise = this.refresh() + .catch((err) => log.error({ err }, 'limiter refresh failed')) + .finally(() => { + this.timer = setTimeout(() => this.poll(), 30 * SECOND) + }) + } + + async destroy() { + this.destroyed = true + if (this.timer) { + clearTimeout(this.timer) + } + await this.promise + } + + async refresh() { + const flags = await this.getRuntimeFlags() + const accountsInPeriod = + flags.periodMs === 0 ? 0 : await this.accountsInPeriod(flags.periodMs) + + this.status = { + ...flags, + accountsInPeriod, + } + + log.info({ ...this.status }, 'limiter refresh') + } + + async getRuntimeFlags(): Promise { + const flagsRes = await this.db.db + .selectFrom('runtime_flag') + .selectAll() + .where('name', '=', DISABLE_SIGNUPS_FLAG) + .orWhere('name', '=', PERIOD_ALLOWANCE_FLAG) + .orWhere('name', '=', PERIOD_MS_FLAG) + .execute() + const disableSignups = + flagsRes.find((val) => val.name === DISABLE_SIGNUPS_FLAG)?.value ?? + 'false' + const periodAllowanceFlag = + flagsRes.find((val) => val.name === PERIOD_ALLOWANCE_FLAG)?.value ?? + '10000000' + const periodAllowance = parseInt(periodAllowanceFlag) + const periodMsFlag = + flagsRes.find((val) => val.name === PERIOD_MS_FLAG)?.value ?? '0' + const periodMs = parseInt(periodMsFlag) + + return { + disableSignups: disableSignups === 'true', + periodAllowance: isNaN(periodAllowance) ? 10000000 : periodAllowance, + periodMs: isNaN(periodMs) ? 10000000 : periodMs, + } + } + + async accountsInPeriod(period: number): Promise { + const hourAgo = new Date(Date.now() - period).toISOString() + const res = await this.db.db + .selectFrom('user_account') + .select(countAll.as('count')) + .where('createdAt', '>', hourAgo) + .executeTakeFirstOrThrow() + return res.count } } + +const DISABLE_SIGNUPS_FLAG = 'signup-limiter:disableSignups' +const PERIOD_ALLOWANCE_FLAG = 'signup-limiter:periodAllowance' +const PERIOD_MS_FLAG = 'signup-limiter:periodMs' From bec3d564d16df286f9a82c070f75c833da617255 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 17:46:34 -0600 Subject: [PATCH 064/135] new schemas & fleshing out signup queue --- .../com/atproto/server/createAccount.json | 3 +- .../atproto/temp/checkSignupAvailability.json | 27 ----- .../com/atproto/temp/checkSignupQueue.json | 22 ++++ .../atproto/temp/sendSignupQueueEmails.json | 17 --- packages/api/src/client/index.ts | 29 ++--- packages/api/src/client/lexicons.ts | 53 ++------- .../types/com/atproto/server/createAccount.ts | 7 -- ...nupAvailability.ts => checkSignupQueue.ts} | 8 +- .../com/atproto/temp/sendSignupQueueEmails.ts | 29 ----- packages/bsky/src/lexicon/index.ts | 22 +--- packages/bsky/src/lexicon/lexicons.ts | 53 ++------- .../types/com/atproto/server/createAccount.ts | 1 - ...nupAvailability.ts => checkSignupQueue.ts} | 8 +- .../com/atproto/temp/sendSignupQueueEmails.ts | 33 ------ packages/pds/src/account-activator.ts | 111 ++++++++++++++++++ .../api/com/atproto/server/createAccount.ts | 8 +- .../api/com/atproto/server/createSession.ts | 1 + .../api/com/atproto/server/refreshSession.ts | 1 + .../atproto/temp/checkSignupAvailability.ts | 44 ------- .../api/com/atproto/temp/checkSignupQueue.ts | 49 ++++++++ .../pds/src/api/com/atproto/temp/index.ts | 6 +- .../com/atproto/temp/sendSignupQueueEmails.ts | 12 -- packages/pds/src/auth-verifier.ts | 9 ++ packages/pds/src/db/database-schema.ts | 4 +- packages/pds/src/db/tables/queued-account.ts | 10 ++ packages/pds/src/db/tables/queued-email.ts | 11 -- packages/pds/src/db/tables/user-account.ts | 1 + packages/pds/src/lexicon/index.ts | 22 +--- packages/pds/src/lexicon/lexicons.ts | 53 ++------- .../types/com/atproto/server/createAccount.ts | 1 - ...nupAvailability.ts => checkSignupQueue.ts} | 8 +- .../com/atproto/temp/sendSignupQueueEmails.ts | 33 ------ packages/pds/src/services/account/index.ts | 7 +- packages/pds/src/services/auth.ts | 23 +++- packages/pds/src/signup-limiter.ts | 5 +- 35 files changed, 303 insertions(+), 428 deletions(-) delete mode 100644 lexicons/com/atproto/temp/checkSignupAvailability.json create mode 100644 lexicons/com/atproto/temp/checkSignupQueue.json delete mode 100644 lexicons/com/atproto/temp/sendSignupQueueEmails.json rename packages/api/src/client/types/com/atproto/temp/{checkSignupAvailability.ts => checkSignupQueue.ts} (85%) delete mode 100644 packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts rename packages/bsky/src/lexicon/types/com/atproto/temp/{checkSignupAvailability.ts => checkSignupQueue.ts} (90%) delete mode 100644 packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts create mode 100644 packages/pds/src/account-activator.ts delete mode 100644 packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts create mode 100644 packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts delete mode 100644 packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts create mode 100644 packages/pds/src/db/tables/queued-account.ts delete mode 100644 packages/pds/src/db/tables/queued-email.ts rename packages/pds/src/lexicon/types/com/atproto/temp/{checkSignupAvailability.ts => checkSignupQueue.ts} (90%) delete mode 100644 packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts diff --git a/lexicons/com/atproto/server/createAccount.json b/lexicons/com/atproto/server/createAccount.json index f998c9846a2..d1456e095ae 100644 --- a/lexicons/com/atproto/server/createAccount.json +++ b/lexicons/com/atproto/server/createAccount.json @@ -44,8 +44,7 @@ { "name": "HandleNotAvailable" }, { "name": "UnsupportedDomain" }, { "name": "UnresolvableDid" }, - { "name": "IncompatibleDidDoc" }, - { "name": "SignupCapacity" } + { "name": "IncompatibleDidDoc" } ] } } diff --git a/lexicons/com/atproto/temp/checkSignupAvailability.json b/lexicons/com/atproto/temp/checkSignupAvailability.json deleted file mode 100644 index b7ec92fcee4..00000000000 --- a/lexicons/com/atproto/temp/checkSignupAvailability.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.checkSignupAvailability", - "defs": { - "main": { - "type": "query", - "description": "Check if the service has availability for new accounts and submit email for waitlist queue.", - "parameters": { - "type": "params", - "required": ["email"], - "properties": { - "email": { "type": "string" } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["hasAvailability"], - "properties": { - "hasAvailability": { "type": "boolean" } - } - } - } - } - } -} diff --git a/lexicons/com/atproto/temp/checkSignupQueue.json b/lexicons/com/atproto/temp/checkSignupQueue.json new file mode 100644 index 00000000000..b7d9e65d7cf --- /dev/null +++ b/lexicons/com/atproto/temp/checkSignupQueue.json @@ -0,0 +1,22 @@ +{ + "lexicon": 1, + "id": "com.atproto.temp.checkSignupQueue", + "defs": { + "main": { + "type": "query", + "description": "Check accounts location in signup queue.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["activated"], + "properties": { + "activated": { "type": "boolean" }, + "placeInQueue": { "type": "integer" }, + "estimatedTimeMs": { "type": "integer" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/temp/sendSignupQueueEmails.json b/lexicons/com/atproto/temp/sendSignupQueueEmails.json deleted file mode 100644 index 9e8092fa7f9..00000000000 --- a/lexicons/com/atproto/temp/sendSignupQueueEmails.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "lexicon": 1, - "id": "com.atproto.temp.sendSignupQueueEmails", - "defs": { - "main": { - "type": "query", - "description": "Fetch all labels from a labeler created after a certain date.", - "parameters": { - "type": "params", - "required": ["count"], - "properties": { - "count": { "type": "integer" } - } - } - } - } -} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index a6f5da1b80d..50e0fd6c0c9 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -81,12 +81,11 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' -import * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' +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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -228,12 +227,11 @@ export * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' export * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' export * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' export * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' -export * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' +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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -1209,14 +1207,14 @@ export class ComAtprotoTempNS { this._service = service } - checkSignupAvailability( - params?: ComAtprotoTempCheckSignupAvailability.QueryParams, - opts?: ComAtprotoTempCheckSignupAvailability.CallOptions, - ): Promise { + checkSignupQueue( + params?: ComAtprotoTempCheckSignupQueue.QueryParams, + opts?: ComAtprotoTempCheckSignupQueue.CallOptions, + ): Promise { return this._service.xrpc - .call('com.atproto.temp.checkSignupAvailability', params, undefined, opts) + .call('com.atproto.temp.checkSignupQueue', params, undefined, opts) .catch((e) => { - throw ComAtprotoTempCheckSignupAvailability.toKnownErr(e) + throw ComAtprotoTempCheckSignupQueue.toKnownErr(e) }) } @@ -1264,17 +1262,6 @@ export class ComAtprotoTempNS { }) } - sendSignupQueueEmails( - params?: ComAtprotoTempSendSignupQueueEmails.QueryParams, - opts?: ComAtprotoTempSendSignupQueueEmails.CallOptions, - ): Promise { - return this._service.xrpc - .call('com.atproto.temp.sendSignupQueueEmails', params, undefined, opts) - .catch((e) => { - throw ComAtprotoTempSendSignupQueueEmails.toKnownErr(e) - }) - } - transferAccount( data?: ComAtprotoTempTransferAccount.InputSchema, opts?: ComAtprotoTempTransferAccount.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index ea33e3d8666..d15e52eb1ad 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2921,9 +2921,6 @@ export const schemaDict = { { name: 'IncompatibleDidDoc', }, - { - name: 'SignupCapacity', - }, ], }, }, @@ -4249,32 +4246,28 @@ export const schemaDict = { }, }, }, - ComAtprotoTempCheckSignupAvailability: { + ComAtprotoTempCheckSignupQueue: { lexicon: 1, - id: 'com.atproto.temp.checkSignupAvailability', + id: 'com.atproto.temp.checkSignupQueue', defs: { main: { type: 'query', - description: - 'Check if the service has availability for new accounts and submit email for waitlist queue.', - parameters: { - type: 'params', - required: ['email'], - properties: { - email: { - type: 'string', - }, - }, - }, + description: 'Check accounts location in signup queue.', output: { encoding: 'application/json', schema: { type: 'object', - required: ['hasAvailability'], + required: ['activated'], properties: { - hasAvailability: { + activated: { type: 'boolean', }, + placeInQueue: { + type: 'integer', + }, + estimatedTimeMs: { + type: 'integer', + }, }, }, }, @@ -4398,26 +4391,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempSendSignupQueueEmails: { - lexicon: 1, - id: 'com.atproto.temp.sendSignupQueueEmails', - defs: { - main: { - type: 'query', - description: - 'Fetch all labels from a labeler created after a certain date.', - parameters: { - type: 'params', - required: ['count'], - properties: { - count: { - type: 'integer', - }, - }, - }, - }, - }, - }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -8275,14 +8248,12 @@ export const ids = { ComAtprotoSyncNotifyOfUpdate: 'com.atproto.sync.notifyOfUpdate', ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', - ComAtprotoTempCheckSignupAvailability: - 'com.atproto.temp.checkSignupAvailability', + 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', - ComAtprotoTempSendSignupQueueEmails: 'com.atproto.temp.sendSignupQueueEmails', ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', diff --git a/packages/api/src/client/types/com/atproto/server/createAccount.ts b/packages/api/src/client/types/com/atproto/server/createAccount.ts index b702623385a..b62adf97cb1 100644 --- a/packages/api/src/client/types/com/atproto/server/createAccount.ts +++ b/packages/api/src/client/types/com/atproto/server/createAccount.ts @@ -85,12 +85,6 @@ export class IncompatibleDidDocError extends XRPCError { } } -export class SignupCapacityError 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) @@ -100,7 +94,6 @@ export function toKnownErr(e: any) { if (e.error === 'UnsupportedDomain') return new UnsupportedDomainError(e) if (e.error === 'UnresolvableDid') return new UnresolvableDidError(e) if (e.error === 'IncompatibleDidDoc') return new IncompatibleDidDocError(e) - if (e.error === 'SignupCapacity') return new SignupCapacityError(e) } return e } diff --git a/packages/api/src/client/types/com/atproto/temp/checkSignupAvailability.ts b/packages/api/src/client/types/com/atproto/temp/checkSignupQueue.ts similarity index 85% rename from packages/api/src/client/types/com/atproto/temp/checkSignupAvailability.ts rename to packages/api/src/client/types/com/atproto/temp/checkSignupQueue.ts index 39baf9b3a05..2f80322c82e 100644 --- a/packages/api/src/client/types/com/atproto/temp/checkSignupAvailability.ts +++ b/packages/api/src/client/types/com/atproto/temp/checkSignupQueue.ts @@ -7,14 +7,14 @@ import { isObj, hasProp } from '../../../../util' import { lexicons } from '../../../../lexicons' import { CID } from 'multiformats/cid' -export interface QueryParams { - email: string -} +export interface QueryParams {} export type InputSchema = undefined export interface OutputSchema { - hasAvailability: boolean + activated: boolean + placeInQueue?: number + estimatedTimeMs?: number [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts b/packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts deleted file mode 100644 index 9e5f7073088..00000000000 --- a/packages/api/src/client/types/com/atproto/temp/sendSignupQueueEmails.ts +++ /dev/null @@ -1,29 +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 { - count: number -} - -export type InputSchema = undefined - -export interface CallOptions { - headers?: Headers -} - -export interface Response { - success: boolean - headers: Headers -} - -export function toKnownErr(e: any) { - if (e instanceof XRPCError) { - } - return e -} diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 1c9caa30336..88b2ad43465 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -78,12 +78,11 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' -import * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' +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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -1022,14 +1021,14 @@ export class ComAtprotoTempNS { this._server = server } - checkSignupAvailability( + checkSignupQueue( cfg: ConfigOf< AV, - ComAtprotoTempCheckSignupAvailability.Handler>, - ComAtprotoTempCheckSignupAvailability.HandlerReqCtx> + ComAtprotoTempCheckSignupQueue.Handler>, + ComAtprotoTempCheckSignupQueue.HandlerReqCtx> >, ) { - const nsid = 'com.atproto.temp.checkSignupAvailability' // @ts-ignore + const nsid = 'com.atproto.temp.checkSignupQueue' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } @@ -1077,17 +1076,6 @@ export class ComAtprotoTempNS { return this._server.xrpc.method(nsid, cfg) } - sendSignupQueueEmails( - cfg: ConfigOf< - AV, - ComAtprotoTempSendSignupQueueEmails.Handler>, - ComAtprotoTempSendSignupQueueEmails.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.sendSignupQueueEmails' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - transferAccount( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index ea33e3d8666..d15e52eb1ad 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2921,9 +2921,6 @@ export const schemaDict = { { name: 'IncompatibleDidDoc', }, - { - name: 'SignupCapacity', - }, ], }, }, @@ -4249,32 +4246,28 @@ export const schemaDict = { }, }, }, - ComAtprotoTempCheckSignupAvailability: { + ComAtprotoTempCheckSignupQueue: { lexicon: 1, - id: 'com.atproto.temp.checkSignupAvailability', + id: 'com.atproto.temp.checkSignupQueue', defs: { main: { type: 'query', - description: - 'Check if the service has availability for new accounts and submit email for waitlist queue.', - parameters: { - type: 'params', - required: ['email'], - properties: { - email: { - type: 'string', - }, - }, - }, + description: 'Check accounts location in signup queue.', output: { encoding: 'application/json', schema: { type: 'object', - required: ['hasAvailability'], + required: ['activated'], properties: { - hasAvailability: { + activated: { type: 'boolean', }, + placeInQueue: { + type: 'integer', + }, + estimatedTimeMs: { + type: 'integer', + }, }, }, }, @@ -4398,26 +4391,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempSendSignupQueueEmails: { - lexicon: 1, - id: 'com.atproto.temp.sendSignupQueueEmails', - defs: { - main: { - type: 'query', - description: - 'Fetch all labels from a labeler created after a certain date.', - parameters: { - type: 'params', - required: ['count'], - properties: { - count: { - type: 'integer', - }, - }, - }, - }, - }, - }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -8275,14 +8248,12 @@ export const ids = { ComAtprotoSyncNotifyOfUpdate: 'com.atproto.sync.notifyOfUpdate', ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', - ComAtprotoTempCheckSignupAvailability: - 'com.atproto.temp.checkSignupAvailability', + 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', - ComAtprotoTempSendSignupQueueEmails: 'com.atproto.temp.sendSignupQueueEmails', ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts index 4784a9f0df3..bbf2c009bf5 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createAccount.ts @@ -54,7 +54,6 @@ export interface HandlerError { | 'UnsupportedDomain' | 'UnresolvableDid' | 'IncompatibleDidDoc' - | 'SignupCapacity' } export type HandlerOutput = HandlerError | HandlerSuccess diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupQueue.ts similarity index 90% rename from packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts rename to packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupQueue.ts index cd462bfee7d..d2a431430a8 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/temp/checkSignupQueue.ts @@ -8,14 +8,14 @@ import { isObj, hasProp } from '../../../../util' import { CID } from 'multiformats/cid' import { HandlerAuth } from '@atproto/xrpc-server' -export interface QueryParams { - email: string -} +export interface QueryParams {} export type InputSchema = undefined export interface OutputSchema { - hasAvailability: boolean + activated: boolean + placeInQueue?: number + estimatedTimeMs?: number [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts b/packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts deleted file mode 100644 index 1cb21d8baee..00000000000 --- a/packages/bsky/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts +++ /dev/null @@ -1,33 +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 } from '@atproto/xrpc-server' - -export interface QueryParams { - count: number -} - -export type InputSchema = undefined -export type HandlerInput = undefined - -export interface HandlerError { - status: number - message?: string -} - -export type HandlerOutput = HandlerError | void -export type HandlerReqCtx = { - auth: HA - params: QueryParams - input: HandlerInput - req: express.Request - res: express.Response -} -export type Handler = ( - ctx: HandlerReqCtx, -) => Promise | HandlerOutput diff --git a/packages/pds/src/account-activator.ts b/packages/pds/src/account-activator.ts new file mode 100644 index 00000000000..8b1e1f13f04 --- /dev/null +++ b/packages/pds/src/account-activator.ts @@ -0,0 +1,111 @@ +import { SECOND } from '@atproto/common' +import { limiterLogger as log } from './logger' +import Database from './db' +import { countAll } from './db/util' +import { Leader } from './db/leader' + +type LimiterFlags = { + disableSignups: boolean + periodAllowance: number + periodMs: number +} + +type LimiterStatus = LimiterFlags & { + accountsInPeriod: number +} + +export const ACCOUNT_ACTIVATOR_ID = 1010 + +export class AccountActivator { + leader: Leader + + destroyed = false + promise: Promise = Promise.resolve() + timer: NodeJS.Timer | undefined + status: LimiterStatus + + constructor(private db: Database, lockId = ACCOUNT_ACTIVATOR_ID) { + this.leader = new Leader(lockId, this.db) + } + + hasAvailability(): boolean { + if (this.status.disableSignups) return false + return this.status.accountsInPeriod < this.status.periodAllowance + } + + async start() { + this.poll() + await this.promise + } + + poll() { + if (this.destroyed) return + this.promise = this.refresh() + .catch((err) => log.error({ err }, 'limiter refresh failed')) + .finally(() => { + this.timer = setTimeout(() => this.poll(), 30 * SECOND) + }) + } + + async destroy() { + this.destroyed = true + if (this.timer) { + clearTimeout(this.timer) + } + await this.promise + } + + async refresh() { + const flags = await this.getRuntimeFlags() + const accountsInPeriod = + flags.periodMs === 0 ? 0 : await this.accountsInPeriod(flags.periodMs) + + this.status = { + ...flags, + accountsInPeriod, + } + + log.info({ ...this.status }, 'limiter refresh') + } + + async getRuntimeFlags(): Promise { + const flagsRes = await this.db.db + .selectFrom('runtime_flag') + .selectAll() + .where('name', '=', DISABLE_SIGNUPS_FLAG) + .orWhere('name', '=', PERIOD_ALLOWANCE_FLAG) + .orWhere('name', '=', PERIOD_MS_FLAG) + .execute() + const disableSignups = + flagsRes.find((val) => val.name === DISABLE_SIGNUPS_FLAG)?.value ?? + 'false' + const periodAllowanceFlag = + flagsRes.find((val) => val.name === PERIOD_ALLOWANCE_FLAG)?.value ?? + '10000000' + const periodAllowance = parseInt(periodAllowanceFlag) + const periodMsFlag = + flagsRes.find((val) => val.name === PERIOD_MS_FLAG)?.value ?? '0' + const periodMs = parseInt(periodMsFlag) + + return { + disableSignups: disableSignups === 'true', + periodAllowance: isNaN(periodAllowance) ? 10000000 : periodAllowance, + periodMs: isNaN(periodMs) ? 10000000 : periodMs, + } + } + + async accountsInPeriod(period: number): Promise { + const periodStart = new Date(Date.now() - period).toISOString() + const res = await this.db.db + .selectFrom('user_account') + .select(countAll.as('count')) + .where('activatedAt', 'is not', null) + .where('activatedAt', '>', periodStart) + .executeTakeFirstOrThrow() + return res.count + } +} + +const DISABLE_SIGNUPS_FLAG = 'signup-limiter:disableSignups' +const PERIOD_ALLOWANCE_FLAG = 'signup-limiter:periodAllowance' +const PERIOD_MS_FLAG = 'signup-limiter:periodMs' diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index fa86d4ede0e..fd5a0ca14f4 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -26,12 +26,6 @@ export default function (server: Server, ctx: AppContext) { }, handler: async ({ input, req }) => { const hasAvailability = ctx.signupLimiter.hasAvailability() - if (!hasAvailability) { - throw new InvalidRequestError( - 'Service at signup capacity, please check back later.', - 'SignupCapacity', - ) - } const { did, @@ -95,6 +89,7 @@ export default function (server: Server, ctx: AppContext) { did, pdsId: entrywayAssignedPds?.id, passwordScrypt, + activated: hasAvailability, }) } catch (err) { if (err instanceof UserAlreadyExistsError) { @@ -149,6 +144,7 @@ export default function (server: Server, ctx: AppContext) { did, pdsDid: entrywayAssignedPds?.did ?? null, appPasswordName: null, + deactivated: !hasAvailability, }) if (entrywayAssignedPds) { diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index df1d2e2d68b..affd76db1a9 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -60,6 +60,7 @@ export default function (server: Server, ctx: AppContext) { did: user.did, pdsDid: user.pdsDid, appPasswordName, + deactivated: !user.activatedAt, }), didDocForSession(ctx, user), ]) diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index 1b0c190707b..3e7ab3a9f03 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -28,6 +28,7 @@ export default function (server: Server, ctx: AppContext) { return ctx.services.auth(dbTxn).rotateRefreshToken({ id: auth.credentials.tokenId, pdsDid: user.pdsDid, + deactived: !user.activatedAt, }) }), ]) diff --git a/packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts b/packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts deleted file mode 100644 index 56d5d04928e..00000000000 --- a/packages/pds/src/api/com/atproto/temp/checkSignupAvailability.ts +++ /dev/null @@ -1,44 +0,0 @@ -import disposable from 'disposable-email' -import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' -import { InvalidRequestError } from '@atproto/xrpc-server' - -export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.checkSignupAvailability({ - handler: async ({ params }) => { - const email = params.email.toLowerCase() - if (!disposable.validate(email)) { - throw new InvalidRequestError( - 'This email address is not supported, please use a different email.', - ) - } - - const alreadyExists = await ctx.db.db - .selectFrom('user_account') - .selectAll() - .where('email', '=', email) - .executeTakeFirst() - if (alreadyExists) { - throw new InvalidRequestError(`Email already taken: ${email}`) - } - - await ctx.db.db - .insertInto('queued_email') - .values({ - email, - registeredAt: new Date().toISOString(), - }) - .onConflict((oc) => oc.doNothing()) - .execute() - - const hasAvailability = ctx.signupLimiter.hasAvailability() - - return { - encoding: 'application/json', - body: { - hasAvailability, - }, - } - }, - }) -} diff --git a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts new file mode 100644 index 00000000000..36210d4aca9 --- /dev/null +++ b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts @@ -0,0 +1,49 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { countAll } from '../../../../db/util' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.temp.checkSignupQueue({ + auth: ctx.authVerifier.accessDeactived, + handler: async ({ auth }) => { + const requester = auth.credentials.did + const account = await ctx.db.db + .selectFrom('user_account') + .selectAll() + .where('did', '=', requester) + .executeTakeFirstOrThrow() + const activated = !!account.activatedAt + let placeInQueue: number | undefined + if (!activated) { + const res = await ctx.db.db + .selectFrom('user_account') + .select(countAll.as('count')) + .where('user_account.activatedAt', 'is', null) + .where('user_account.createdAt', '<', account.createdAt) + .executeTakeFirst() + placeInQueue = res?.count + } + const limiterStatus = ctx.signupLimiter.status + let estimatedTimeMs: number | undefined + if ( + placeInQueue && + limiterStatus.disableSignups && + limiterStatus.accountsInPeriod > 0 + ) { + estimatedTimeMs = + (placeInQueue * limiterStatus.periodMs) / + limiterStatus.accountsInPeriod + } + + return { + encoding: 'application/json', + body: { + activated, + placeInQueue, + estimatedTimeMs, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/temp/index.ts b/packages/pds/src/api/com/atproto/temp/index.ts index 90f2122101a..f07a0d5a1c2 100644 --- a/packages/pds/src/api/com/atproto/temp/index.ts +++ b/packages/pds/src/api/com/atproto/temp/index.ts @@ -1,11 +1,9 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' +import checkSignupQueue from './checkSignupQueue' import requestPhoneVerification from './requestPhoneVerification' -import checkSignupAvailability from './checkSignupAvailability' -import sendSignupQueueEmails from './sendSignupQueueEmails' export default function (server: Server, ctx: AppContext) { requestPhoneVerification(server, ctx) - checkSignupAvailability(server, ctx) - sendSignupQueueEmails(server, ctx) + checkSignupQueue(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts b/packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts deleted file mode 100644 index f0f06aafd6b..00000000000 --- a/packages/pds/src/api/com/atproto/temp/sendSignupQueueEmails.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' -import { InvalidRequestError } from '@atproto/xrpc-server' -import { countAll } from '../../../../db/util' - -export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.sendSignupQueueEmails({ - handler: async () => { - throw new Error('unimplemented') - }, - }) -} diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index cba9094fb34..647ef359e42 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -29,6 +29,7 @@ export enum AuthScope { Access = 'com.atproto.access', Refresh = 'com.atproto.refresh', AppPass = 'com.atproto.appPass', + Deactivated = 'com.atproto.deactived', } export enum RoleStatus { @@ -166,6 +167,14 @@ export class AuthVerifier { return this.validateAccessToken(ctx.req, [AuthScope.Access]) } + accessDeactived = (ctx: ReqCtx): Promise => { + return this.validateAccessToken(ctx.req, [ + AuthScope.Access, + AuthScope.AppPass, + AuthScope.Deactivated, + ]) + } + // @TODO additional check on aud when set refresh = async (ctx: ReqCtx): Promise => { const { did, scope, token, audience, payload } = diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 727a774953b..89f94c73e63 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -19,7 +19,7 @@ import * as repoSeq from './tables/repo-seq' import * as appMigration from './tables/app-migration' import * as runtimeFlag from './tables/runtime-flag' import * as phoneVerification from './tables/phone-verification' -import * as queuedEmail from './tables/queued-email' +import * as queuedAccount from './tables/queued-account' export type DatabaseSchemaType = appMigration.PartialDB & runtimeFlag.PartialDB & @@ -41,7 +41,7 @@ export type DatabaseSchemaType = appMigration.PartialDB & moderation.PartialDB & repoSeq.PartialDB & phoneVerification.PartialDB & - queuedEmail.PartialDB + queuedAccount.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/pds/src/db/tables/queued-account.ts b/packages/pds/src/db/tables/queued-account.ts new file mode 100644 index 00000000000..524b21b001c --- /dev/null +++ b/packages/pds/src/db/tables/queued-account.ts @@ -0,0 +1,10 @@ +export interface QueuedAccount { + did: string + activatedAt: string | null +} + +export const tableName = 'queued_account' + +export type PartialDB = { + [tableName]: QueuedAccount +} diff --git a/packages/pds/src/db/tables/queued-email.ts b/packages/pds/src/db/tables/queued-email.ts deleted file mode 100644 index aff839df106..00000000000 --- a/packages/pds/src/db/tables/queued-email.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface QueuedEmail { - email: string - registeredAt: string - lastEmailed: string | null -} - -export const tableName = 'queued_email' - -export type PartialDB = { - [tableName]: QueuedEmail -} diff --git a/packages/pds/src/db/tables/user-account.ts b/packages/pds/src/db/tables/user-account.ts index 5c7c1b2dcea..0b2e7c55599 100644 --- a/packages/pds/src/db/tables/user-account.ts +++ b/packages/pds/src/db/tables/user-account.ts @@ -5,6 +5,7 @@ export interface UserAccount { email: string passwordScrypt: string createdAt: string + activatedAt: string | null emailConfirmedAt: string | null invitesDisabled: Generated<0 | 1> inviteNote: string | null diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 1c9caa30336..88b2ad43465 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -78,12 +78,11 @@ import * as ComAtprotoSyncListRepos from './types/com/atproto/sync/listRepos' import * as ComAtprotoSyncNotifyOfUpdate from './types/com/atproto/sync/notifyOfUpdate' import * as ComAtprotoSyncRequestCrawl from './types/com/atproto/sync/requestCrawl' import * as ComAtprotoSyncSubscribeRepos from './types/com/atproto/sync/subscribeRepos' -import * as ComAtprotoTempCheckSignupAvailability from './types/com/atproto/temp/checkSignupAvailability' +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 ComAtprotoTempSendSignupQueueEmails from './types/com/atproto/temp/sendSignupQueueEmails' 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' @@ -1022,14 +1021,14 @@ export class ComAtprotoTempNS { this._server = server } - checkSignupAvailability( + checkSignupQueue( cfg: ConfigOf< AV, - ComAtprotoTempCheckSignupAvailability.Handler>, - ComAtprotoTempCheckSignupAvailability.HandlerReqCtx> + ComAtprotoTempCheckSignupQueue.Handler>, + ComAtprotoTempCheckSignupQueue.HandlerReqCtx> >, ) { - const nsid = 'com.atproto.temp.checkSignupAvailability' // @ts-ignore + const nsid = 'com.atproto.temp.checkSignupQueue' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } @@ -1077,17 +1076,6 @@ export class ComAtprotoTempNS { return this._server.xrpc.method(nsid, cfg) } - sendSignupQueueEmails( - cfg: ConfigOf< - AV, - ComAtprotoTempSendSignupQueueEmails.Handler>, - ComAtprotoTempSendSignupQueueEmails.HandlerReqCtx> - >, - ) { - const nsid = 'com.atproto.temp.sendSignupQueueEmails' // @ts-ignore - return this._server.xrpc.method(nsid, cfg) - } - transferAccount( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index ea33e3d8666..d15e52eb1ad 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2921,9 +2921,6 @@ export const schemaDict = { { name: 'IncompatibleDidDoc', }, - { - name: 'SignupCapacity', - }, ], }, }, @@ -4249,32 +4246,28 @@ export const schemaDict = { }, }, }, - ComAtprotoTempCheckSignupAvailability: { + ComAtprotoTempCheckSignupQueue: { lexicon: 1, - id: 'com.atproto.temp.checkSignupAvailability', + id: 'com.atproto.temp.checkSignupQueue', defs: { main: { type: 'query', - description: - 'Check if the service has availability for new accounts and submit email for waitlist queue.', - parameters: { - type: 'params', - required: ['email'], - properties: { - email: { - type: 'string', - }, - }, - }, + description: 'Check accounts location in signup queue.', output: { encoding: 'application/json', schema: { type: 'object', - required: ['hasAvailability'], + required: ['activated'], properties: { - hasAvailability: { + activated: { type: 'boolean', }, + placeInQueue: { + type: 'integer', + }, + estimatedTimeMs: { + type: 'integer', + }, }, }, }, @@ -4398,26 +4391,6 @@ export const schemaDict = { }, }, }, - ComAtprotoTempSendSignupQueueEmails: { - lexicon: 1, - id: 'com.atproto.temp.sendSignupQueueEmails', - defs: { - main: { - type: 'query', - description: - 'Fetch all labels from a labeler created after a certain date.', - parameters: { - type: 'params', - required: ['count'], - properties: { - count: { - type: 'integer', - }, - }, - }, - }, - }, - }, ComAtprotoTempTransferAccount: { lexicon: 1, id: 'com.atproto.temp.transferAccount', @@ -8275,14 +8248,12 @@ export const ids = { ComAtprotoSyncNotifyOfUpdate: 'com.atproto.sync.notifyOfUpdate', ComAtprotoSyncRequestCrawl: 'com.atproto.sync.requestCrawl', ComAtprotoSyncSubscribeRepos: 'com.atproto.sync.subscribeRepos', - ComAtprotoTempCheckSignupAvailability: - 'com.atproto.temp.checkSignupAvailability', + 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', - ComAtprotoTempSendSignupQueueEmails: 'com.atproto.temp.sendSignupQueueEmails', ComAtprotoTempTransferAccount: 'com.atproto.temp.transferAccount', AppBskyActorDefs: 'app.bsky.actor.defs', AppBskyActorGetPreferences: 'app.bsky.actor.getPreferences', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts index 4784a9f0df3..bbf2c009bf5 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createAccount.ts @@ -54,7 +54,6 @@ export interface HandlerError { | 'UnsupportedDomain' | 'UnresolvableDid' | 'IncompatibleDidDoc' - | 'SignupCapacity' } export type HandlerOutput = HandlerError | HandlerSuccess diff --git a/packages/pds/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts b/packages/pds/src/lexicon/types/com/atproto/temp/checkSignupQueue.ts similarity index 90% rename from packages/pds/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts rename to packages/pds/src/lexicon/types/com/atproto/temp/checkSignupQueue.ts index cd462bfee7d..d2a431430a8 100644 --- a/packages/pds/src/lexicon/types/com/atproto/temp/checkSignupAvailability.ts +++ b/packages/pds/src/lexicon/types/com/atproto/temp/checkSignupQueue.ts @@ -8,14 +8,14 @@ import { isObj, hasProp } from '../../../../util' import { CID } from 'multiformats/cid' import { HandlerAuth } from '@atproto/xrpc-server' -export interface QueryParams { - email: string -} +export interface QueryParams {} export type InputSchema = undefined export interface OutputSchema { - hasAvailability: boolean + activated: boolean + placeInQueue?: number + estimatedTimeMs?: number [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts b/packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts deleted file mode 100644 index 1cb21d8baee..00000000000 --- a/packages/pds/src/lexicon/types/com/atproto/temp/sendSignupQueueEmails.ts +++ /dev/null @@ -1,33 +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 } from '@atproto/xrpc-server' - -export interface QueryParams { - count: number -} - -export type InputSchema = undefined -export type HandlerInput = undefined - -export interface HandlerError { - status: number - message?: string -} - -export type HandlerOutput = HandlerError | void -export type HandlerReqCtx = { - auth: HA - params: QueryParams - input: HandlerInput - req: express.Request - res: express.Response -} -export type Handler = ( - ctx: HandlerReqCtx, -) => Promise | HandlerOutput diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index f52ef9d9bb7..f7fa571b64f 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -129,10 +129,12 @@ export class AccountService { did: string pdsId?: number passwordScrypt: string + activated: boolean }) { this.db.assertTransaction() - const { email, handle, did, pdsId, passwordScrypt } = opts + const { email, handle, did, pdsId, passwordScrypt, activated } = opts log.debug({ handle, email }, 'registering user') + const createdAt = new Date().toISOString() const registerUserAccnt = this.db.db .insertInto('user_account') .values({ @@ -140,7 +142,8 @@ export class AccountService { did, pdsId, passwordScrypt, - createdAt: new Date().toISOString(), + createdAt, + activatedAt: activated ? createdAt : null, }) .onConflict((oc) => oc.doNothing()) .returning('did') diff --git a/packages/pds/src/services/auth.ts b/packages/pds/src/services/auth.ts index 706aaa1b4db..9f493f9f97a 100644 --- a/packages/pds/src/services/auth.ts +++ b/packages/pds/src/services/auth.ts @@ -84,13 +84,14 @@ export class AuthService { did: string pdsDid: string | null appPasswordName: string | null + deactivated: boolean }) { - const { did, pdsDid, appPasswordName } = opts + const { did, pdsDid, appPasswordName, deactivated } = opts const [access, refresh] = await Promise.all([ this.createAccessToken({ did, pdsDid, - scope: appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, + scope: determineScope(appPasswordName, deactivated), }), this.createRefreshToken({ did }), ]) @@ -115,7 +116,11 @@ export class AuthService { .executeTakeFirst() } - async rotateRefreshToken(opts: { id: string; pdsDid: string | null }) { + async rotateRefreshToken(opts: { + id: string + pdsDid: string | null + deactived: boolean + }) { this.db.assertTransaction() const { id, pdsDid } = opts const token = await this.db.db @@ -167,8 +172,7 @@ export class AuthService { this.createAccessToken({ did: token.did, pdsDid, - scope: - token.appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, + scope: determineScope(token.appPasswordName, opts.deactived), }), ]) @@ -214,3 +218,12 @@ const decodeRefreshToken = (jwt: string) => { assert.ok(token.scope === AuthScope.Refresh, 'not a refresh token') return token as RefreshToken } + +const determineScope = ( + appPasswordName: string | null, + deactivated: boolean, +): AuthScope => { + if (deactivated) return AuthScope.Deactivated + if (appPasswordName !== null) return AuthScope.AppPass + return AuthScope.Access +} diff --git a/packages/pds/src/signup-limiter.ts b/packages/pds/src/signup-limiter.ts index c53c350afca..746a8cc0c63 100644 --- a/packages/pds/src/signup-limiter.ts +++ b/packages/pds/src/signup-limiter.ts @@ -88,11 +88,12 @@ export class SignupLimiter { } async accountsInPeriod(period: number): Promise { - const hourAgo = new Date(Date.now() - period).toISOString() + const periodStart = new Date(Date.now() - period).toISOString() const res = await this.db.db .selectFrom('user_account') .select(countAll.as('count')) - .where('createdAt', '>', hourAgo) + .where('activatedAt', 'is not', null) + .where('activatedAt', '>', periodStart) .executeTakeFirstOrThrow() return res.count } From 55d664235922035f9c6bfec4eb179332a50037a9 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 18:51:30 -0600 Subject: [PATCH 065/135] refactor & hook up to ctx --- packages/pds/src/account-activator.ts | 111 ------------------ .../api/com/atproto/temp/checkSignupQueue.ts | 3 +- packages/pds/src/context.ts | 8 +- packages/pds/src/signup-limiter.ts | 104 ---------------- packages/pds/src/signup-queue/activator.ts | 104 ++++++++++++++++ packages/pds/src/signup-queue/limiter.ts | 46 ++++++++ packages/pds/src/signup-queue/util.ts | 66 +++++++++++ 7 files changed, 225 insertions(+), 217 deletions(-) delete mode 100644 packages/pds/src/account-activator.ts delete mode 100644 packages/pds/src/signup-limiter.ts create mode 100644 packages/pds/src/signup-queue/activator.ts create mode 100644 packages/pds/src/signup-queue/limiter.ts create mode 100644 packages/pds/src/signup-queue/util.ts diff --git a/packages/pds/src/account-activator.ts b/packages/pds/src/account-activator.ts deleted file mode 100644 index 8b1e1f13f04..00000000000 --- a/packages/pds/src/account-activator.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { SECOND } from '@atproto/common' -import { limiterLogger as log } from './logger' -import Database from './db' -import { countAll } from './db/util' -import { Leader } from './db/leader' - -type LimiterFlags = { - disableSignups: boolean - periodAllowance: number - periodMs: number -} - -type LimiterStatus = LimiterFlags & { - accountsInPeriod: number -} - -export const ACCOUNT_ACTIVATOR_ID = 1010 - -export class AccountActivator { - leader: Leader - - destroyed = false - promise: Promise = Promise.resolve() - timer: NodeJS.Timer | undefined - status: LimiterStatus - - constructor(private db: Database, lockId = ACCOUNT_ACTIVATOR_ID) { - this.leader = new Leader(lockId, this.db) - } - - hasAvailability(): boolean { - if (this.status.disableSignups) return false - return this.status.accountsInPeriod < this.status.periodAllowance - } - - async start() { - this.poll() - await this.promise - } - - poll() { - if (this.destroyed) return - this.promise = this.refresh() - .catch((err) => log.error({ err }, 'limiter refresh failed')) - .finally(() => { - this.timer = setTimeout(() => this.poll(), 30 * SECOND) - }) - } - - async destroy() { - this.destroyed = true - if (this.timer) { - clearTimeout(this.timer) - } - await this.promise - } - - async refresh() { - const flags = await this.getRuntimeFlags() - const accountsInPeriod = - flags.periodMs === 0 ? 0 : await this.accountsInPeriod(flags.periodMs) - - this.status = { - ...flags, - accountsInPeriod, - } - - log.info({ ...this.status }, 'limiter refresh') - } - - async getRuntimeFlags(): Promise { - const flagsRes = await this.db.db - .selectFrom('runtime_flag') - .selectAll() - .where('name', '=', DISABLE_SIGNUPS_FLAG) - .orWhere('name', '=', PERIOD_ALLOWANCE_FLAG) - .orWhere('name', '=', PERIOD_MS_FLAG) - .execute() - const disableSignups = - flagsRes.find((val) => val.name === DISABLE_SIGNUPS_FLAG)?.value ?? - 'false' - const periodAllowanceFlag = - flagsRes.find((val) => val.name === PERIOD_ALLOWANCE_FLAG)?.value ?? - '10000000' - const periodAllowance = parseInt(periodAllowanceFlag) - const periodMsFlag = - flagsRes.find((val) => val.name === PERIOD_MS_FLAG)?.value ?? '0' - const periodMs = parseInt(periodMsFlag) - - return { - disableSignups: disableSignups === 'true', - periodAllowance: isNaN(periodAllowance) ? 10000000 : periodAllowance, - periodMs: isNaN(periodMs) ? 10000000 : periodMs, - } - } - - async accountsInPeriod(period: number): Promise { - const periodStart = new Date(Date.now() - period).toISOString() - const res = await this.db.db - .selectFrom('user_account') - .select(countAll.as('count')) - .where('activatedAt', 'is not', null) - .where('activatedAt', '>', periodStart) - .executeTakeFirstOrThrow() - return res.count - } -} - -const DISABLE_SIGNUPS_FLAG = 'signup-limiter:disableSignups' -const PERIOD_ALLOWANCE_FLAG = 'signup-limiter:periodAllowance' -const PERIOD_MS_FLAG = 'signup-limiter:periodMs' diff --git a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts index 36210d4aca9..6c6d201deff 100644 --- a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts +++ b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts @@ -1,6 +1,5 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { InvalidRequestError } from '@atproto/xrpc-server' import { countAll } from '../../../../db/util' export default function (server: Server, ctx: AppContext) { @@ -14,6 +13,7 @@ export default function (server: Server, ctx: AppContext) { .where('did', '=', requester) .executeTakeFirstOrThrow() const activated = !!account.activatedAt + let placeInQueue: number | undefined if (!activated) { const res = await ctx.db.db @@ -24,6 +24,7 @@ export default function (server: Server, ctx: AppContext) { .executeTakeFirst() placeInQueue = res?.count } + const limiterStatus = ctx.signupLimiter.status let estimatedTimeMs: number | undefined if ( diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index b5464a76866..75224e3c83a 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -23,7 +23,8 @@ import { RuntimeFlags } from './runtime-flags' import { PdsAgents } from './pds-agents' import { TwilioClient } from './twilio' import assert from 'assert' -import { SignupLimiter } from './signup-limiter' +import { SignupLimiter } from './signup-queue/limiter' +import { SignupActivator } from './signup-queue/activator' export type AppContextOptions = { db: Database @@ -48,6 +49,7 @@ export type AppContextOptions = { plcRotationKey: crypto.Keypair twilio?: TwilioClient signupLimiter: SignupLimiter + signupActivator: SignupActivator cfg: ServerConfig } @@ -74,6 +76,7 @@ export class AppContext { public plcRotationKey: crypto.Keypair public twilio?: TwilioClient public signupLimiter: SignupLimiter + public signupActivator: SignupActivator public cfg: ServerConfig constructor(opts: AppContextOptions) { @@ -99,6 +102,7 @@ export class AppContext { this.plcRotationKey = opts.plcRotationKey this.twilio = opts.twilio this.signupLimiter = opts.signupLimiter + this.signupActivator = opts.signupActivator this.cfg = opts.cfg } @@ -227,6 +231,7 @@ export class AppContext { } const signupLimiter = new SignupLimiter(db) + const signupActivator = new SignupActivator(db) const pdsAgents = new PdsAgents() @@ -253,6 +258,7 @@ export class AppContext { pdsAgents, twilio, signupLimiter, + signupActivator, cfg, ...(overrides ?? {}), }) diff --git a/packages/pds/src/signup-limiter.ts b/packages/pds/src/signup-limiter.ts deleted file mode 100644 index 746a8cc0c63..00000000000 --- a/packages/pds/src/signup-limiter.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { SECOND } from '@atproto/common' -import { limiterLogger as log } from './logger' -import Database from './db' -import { countAll } from './db/util' - -type LimiterFlags = { - disableSignups: boolean - periodAllowance: number - periodMs: number -} - -type LimiterStatus = LimiterFlags & { - accountsInPeriod: number -} - -export class SignupLimiter { - destroyed = false - promise: Promise = Promise.resolve() - timer: NodeJS.Timer | undefined - status: LimiterStatus - - constructor(private db: Database) {} - - hasAvailability(): boolean { - if (this.status.disableSignups) return false - return this.status.accountsInPeriod < this.status.periodAllowance - } - - async start() { - this.poll() - await this.promise - } - - poll() { - if (this.destroyed) return - this.promise = this.refresh() - .catch((err) => log.error({ err }, 'limiter refresh failed')) - .finally(() => { - this.timer = setTimeout(() => this.poll(), 30 * SECOND) - }) - } - - async destroy() { - this.destroyed = true - if (this.timer) { - clearTimeout(this.timer) - } - await this.promise - } - - async refresh() { - const flags = await this.getRuntimeFlags() - const accountsInPeriod = - flags.periodMs === 0 ? 0 : await this.accountsInPeriod(flags.periodMs) - - this.status = { - ...flags, - accountsInPeriod, - } - - log.info({ ...this.status }, 'limiter refresh') - } - - async getRuntimeFlags(): Promise { - const flagsRes = await this.db.db - .selectFrom('runtime_flag') - .selectAll() - .where('name', '=', DISABLE_SIGNUPS_FLAG) - .orWhere('name', '=', PERIOD_ALLOWANCE_FLAG) - .orWhere('name', '=', PERIOD_MS_FLAG) - .execute() - const disableSignups = - flagsRes.find((val) => val.name === DISABLE_SIGNUPS_FLAG)?.value ?? - 'false' - const periodAllowanceFlag = - flagsRes.find((val) => val.name === PERIOD_ALLOWANCE_FLAG)?.value ?? - '10000000' - const periodAllowance = parseInt(periodAllowanceFlag) - const periodMsFlag = - flagsRes.find((val) => val.name === PERIOD_MS_FLAG)?.value ?? '0' - const periodMs = parseInt(periodMsFlag) - - return { - disableSignups: disableSignups === 'true', - periodAllowance: isNaN(periodAllowance) ? 10000000 : periodAllowance, - periodMs: isNaN(periodMs) ? 10000000 : periodMs, - } - } - - async accountsInPeriod(period: number): Promise { - const periodStart = new Date(Date.now() - period).toISOString() - const res = await this.db.db - .selectFrom('user_account') - .select(countAll.as('count')) - .where('activatedAt', 'is not', null) - .where('activatedAt', '>', periodStart) - .executeTakeFirstOrThrow() - return res.count - } -} - -const DISABLE_SIGNUPS_FLAG = 'signup-limiter:disableSignups' -const PERIOD_ALLOWANCE_FLAG = 'signup-limiter:periodAllowance' -const PERIOD_MS_FLAG = 'signup-limiter:periodMs' diff --git a/packages/pds/src/signup-queue/activator.ts b/packages/pds/src/signup-queue/activator.ts new file mode 100644 index 00000000000..99cd05c1e4e --- /dev/null +++ b/packages/pds/src/signup-queue/activator.ts @@ -0,0 +1,104 @@ +import { SECOND, jitter, wait } from '@atproto/common' +import { limiterLogger as log } from '../logger' +import Database from '../db' +import { Leader } from '../db/leader' +import { DisconnectError } from '@atproto/xrpc-server' +import { getQueueStatus } from './util' + +type LimiterFlags = { + disableSignups: boolean + periodAllowance: number + periodMs: number +} + +type LimiterStatus = LimiterFlags & { + accountsInPeriod: number +} + +export const ACCOUNT_ACTIVATOR_ID = 1010 + +export class SignupActivator { + leader: Leader + + destroyed = false + promise: Promise = Promise.resolve() + timer: NodeJS.Timer | undefined + status: LimiterStatus + + constructor(private db: Database, lockId = ACCOUNT_ACTIVATOR_ID) { + this.leader = new Leader(lockId, this.db) + } + + async run() { + while (!this.destroyed) + try { + const { ran } = await this.leader.run(async ({ signal }) => { + this.poll() + await new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + const err = signal.reason + if (!err || err instanceof DisconnectError) { + resolve() + } else { + reject(err) + } + }) + }) + }) + if (ran && !this.destroyed) { + throw new Error( + 'Account activator leader completed, but should be persistent', + ) + } + } catch (err) { + log.error({ err }, 'account activator errored') + } finally { + if (!this.destroyed) { + await wait(1000 + jitter(500)) + } + } + } + + poll() { + if (this.destroyed) return + this.promise = this.activateBatch() + .catch((err) => log.error({ err }, 'limiter refresh failed')) + .finally(() => { + this.timer = setTimeout(() => this.poll(), 30 * SECOND) + }) + } + + async destroy() { + this.destroyed = true + if (this.timer) { + clearTimeout(this.timer) + } + this.leader.destroy(new DisconnectError()) + await this.promise + } + + async activateBatch() { + const status = await getQueueStatus(this.db) + if (status.disableSignups) return + const toAdmit = status.periodAllowance - status.accountsInPeriod + if (toAdmit < 1) return + + const activatedAt = new Date().toISOString() + const activated = await this.db.db + .updateTable('user_account') + .set({ activatedAt }) + .where('did', 'in', (qb) => + qb + .selectFrom('user_account') + .select('did') + .where('activatedAt', 'is', null) + .orderBy('createdAt', 'asc') + .limit(toAdmit), + ) + .returning('did') + .execute() + + log.info({ count: activated.length }, 'activated accounts') + // @TODO send mail/push notifs + } +} diff --git a/packages/pds/src/signup-queue/limiter.ts b/packages/pds/src/signup-queue/limiter.ts new file mode 100644 index 00000000000..934d04a73da --- /dev/null +++ b/packages/pds/src/signup-queue/limiter.ts @@ -0,0 +1,46 @@ +import { SECOND } from '@atproto/common' +import { limiterLogger as log } from '../logger' +import Database from '../db' +import { LimiterStatus, getQueueStatus } from './util' + +export class SignupLimiter { + destroyed = false + promise: Promise = Promise.resolve() + timer: NodeJS.Timer | undefined + status: LimiterStatus + + constructor(private db: Database) {} + + hasAvailability(): boolean { + if (this.status.disableSignups) return false + return this.status.accountsInPeriod < this.status.periodAllowance + } + + async start() { + this.poll() + await this.promise + } + + poll() { + if (this.destroyed) return + this.promise = this.refresh() + .catch((err) => log.error({ err }, 'limiter refresh failed')) + .finally(() => { + this.timer = setTimeout(() => this.poll(), 30 * SECOND) + }) + } + + async destroy() { + this.destroyed = true + if (this.timer) { + clearTimeout(this.timer) + } + await this.promise + } + + async refresh() { + this.status = await getQueueStatus(this.db) + + log.info({ ...this.status }, 'limiter refresh') + } +} diff --git a/packages/pds/src/signup-queue/util.ts b/packages/pds/src/signup-queue/util.ts new file mode 100644 index 00000000000..f9730c861b9 --- /dev/null +++ b/packages/pds/src/signup-queue/util.ts @@ -0,0 +1,66 @@ +import Database from '../db' +import { countAll } from '../db/util' + +export type LimiterFlags = { + disableSignups: boolean + periodAllowance: number + periodMs: number +} + +export type LimiterStatus = LimiterFlags & { + accountsInPeriod: number +} + +export const getRuntimeFlags = async (db: Database): Promise => { + const flagsRes = await db.db + .selectFrom('runtime_flag') + .selectAll() + .where('name', '=', DISABLE_SIGNUPS_FLAG) + .orWhere('name', '=', PERIOD_ALLOWANCE_FLAG) + .orWhere('name', '=', PERIOD_MS_FLAG) + .execute() + const disableSignups = + flagsRes.find((val) => val.name === DISABLE_SIGNUPS_FLAG)?.value ?? 'false' + const periodAllowanceFlag = + flagsRes.find((val) => val.name === PERIOD_ALLOWANCE_FLAG)?.value ?? + '10000000' + const periodAllowance = parseInt(periodAllowanceFlag) + const periodMsFlag = + flagsRes.find((val) => val.name === PERIOD_MS_FLAG)?.value ?? '0' + const periodMs = parseInt(periodMsFlag) + + return { + disableSignups: disableSignups === 'true', + periodAllowance: isNaN(periodAllowance) ? 10000000 : periodAllowance, + periodMs: isNaN(periodMs) ? 10000000 : periodMs, + } +} + +export const getAccountsInPeriod = async ( + db: Database, + periodMs: number, +): Promise => { + const periodStart = new Date(Date.now() - periodMs).toISOString() + const res = await db.db + .selectFrom('user_account') + .select(countAll.as('count')) + .where('activatedAt', 'is not', null) + .where('activatedAt', '>', periodStart) + .executeTakeFirstOrThrow() + return res.count +} + +export const getQueueStatus = async (db: Database) => { + const flags = await getRuntimeFlags(db) + const accountsInPeriod = + flags.periodMs === 0 ? 0 : await getAccountsInPeriod(db, flags.periodMs) + + return { + ...flags, + accountsInPeriod, + } +} + +export const DISABLE_SIGNUPS_FLAG = 'signup-limiter:disableSignups' +export const PERIOD_ALLOWANCE_FLAG = 'signup-limiter:periodAllowance' +export const PERIOD_MS_FLAG = 'signup-limiter:periodMs' From 0e56c9101eb5a491b7a33c06d6ea2e4dfd1c0733 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 18:58:15 -0600 Subject: [PATCH 066/135] db migration --- packages/pds/src/db/database-schema.ts | 4 +--- .../20240124T005600811Z-signup-queue.ts | 18 ++++++++++++++++++ packages/pds/src/db/migrations/index.ts | 1 + packages/pds/src/db/tables/queued-account.ts | 10 ---------- 4 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 packages/pds/src/db/migrations/20240124T005600811Z-signup-queue.ts delete mode 100644 packages/pds/src/db/tables/queued-account.ts diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 89f94c73e63..57599aa12d9 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -19,7 +19,6 @@ import * as repoSeq from './tables/repo-seq' import * as appMigration from './tables/app-migration' import * as runtimeFlag from './tables/runtime-flag' import * as phoneVerification from './tables/phone-verification' -import * as queuedAccount from './tables/queued-account' export type DatabaseSchemaType = appMigration.PartialDB & runtimeFlag.PartialDB & @@ -40,8 +39,7 @@ export type DatabaseSchemaType = appMigration.PartialDB & emailToken.PartialDB & moderation.PartialDB & repoSeq.PartialDB & - phoneVerification.PartialDB & - queuedAccount.PartialDB + phoneVerification.PartialDB export type DatabaseSchema = Kysely diff --git a/packages/pds/src/db/migrations/20240124T005600811Z-signup-queue.ts b/packages/pds/src/db/migrations/20240124T005600811Z-signup-queue.ts new file mode 100644 index 00000000000..38fb5c6e399 --- /dev/null +++ b/packages/pds/src/db/migrations/20240124T005600811Z-signup-queue.ts @@ -0,0 +1,18 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('user_account') + .addColumn('activatedAt', 'varchar') + .execute() + await db.schema + .createIndex('user_account_activated_at_idx') + .on('user_account') + .columns(['activatedAt', 'createdAt']) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('user_account_activated_at_idx').execute() + await db.schema.alterTable('user_account').dropColumn('activatedAt').execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index 8b1e10f89f7..a23b1c36275 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -9,3 +9,4 @@ export * as _20230929T213219699Z from './20230929T213219699Z-takedown-id-as-int' export * as _20231011T155513453Z from './20231011T155513453Z-takedown-ref' export * as _20231031T222409283Z from './20231031T222409283Z-user-account-pds' export * as _20240117T001106576Z from './20240117T001106576Z-phone-verification' +export * as _20240124T005600811Z from './20240124T005600811Z-signup-queue' diff --git a/packages/pds/src/db/tables/queued-account.ts b/packages/pds/src/db/tables/queued-account.ts deleted file mode 100644 index 524b21b001c..00000000000 --- a/packages/pds/src/db/tables/queued-account.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface QueuedAccount { - did: string - activatedAt: string | null -} - -export const tableName = 'queued_account' - -export type PartialDB = { - [tableName]: QueuedAccount -} From addeabde031bcbe1ca32b8ba0bd81388a8d8b0fd Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 19:01:55 -0600 Subject: [PATCH 067/135] bad codegen --- packages/api/src/client/index.ts | 102 ++++++++++++++-------------- packages/bsky/src/lexicon/index.ts | 104 ++++++++++++++--------------- packages/pds/src/lexicon/index.ts | 104 ++++++++++++++--------------- 3 files changed, 153 insertions(+), 157 deletions(-) diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 50e0fd6c0c9..673ce911fcc 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -350,39 +350,39 @@ export class AtpServiceClient { export class ComNS { _service: AtpServiceClient - atproto: ComAtprotoNS + atproto: AtprotoNS constructor(service: AtpServiceClient) { this._service = service - this.atproto = new ComAtprotoNS(service) + this.atproto = new AtprotoNS(service) } } -export class ComAtprotoNS { +export class AtprotoNS { _service: AtpServiceClient - admin: ComAtprotoAdminNS - identity: ComAtprotoIdentityNS - label: ComAtprotoLabelNS - moderation: ComAtprotoModerationNS - repo: ComAtprotoRepoNS - server: ComAtprotoServerNS - sync: ComAtprotoSyncNS - temp: ComAtprotoTempNS + admin: AdminNS + identity: IdentityNS + label: LabelNS + moderation: ModerationNS + repo: RepoNS + server: ServerNS + sync: SyncNS + temp: TempNS constructor(service: AtpServiceClient) { this._service = service - this.admin = new ComAtprotoAdminNS(service) - this.identity = new ComAtprotoIdentityNS(service) - this.label = new ComAtprotoLabelNS(service) - this.moderation = new ComAtprotoModerationNS(service) - this.repo = new ComAtprotoRepoNS(service) - this.server = new ComAtprotoServerNS(service) - this.sync = new ComAtprotoSyncNS(service) - this.temp = new ComAtprotoTempNS(service) + this.admin = new AdminNS(service) + this.identity = new IdentityNS(service) + this.label = new LabelNS(service) + this.moderation = new ModerationNS(service) + this.repo = new RepoNS(service) + this.server = new ServerNS(service) + this.sync = new SyncNS(service) + this.temp = new TempNS(service) } } -export class ComAtprotoAdminNS { +export class AdminNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -668,7 +668,7 @@ export class ComAtprotoAdminNS { } } -export class ComAtprotoIdentityNS { +export class IdentityNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -698,7 +698,7 @@ export class ComAtprotoIdentityNS { } } -export class ComAtprotoLabelNS { +export class LabelNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -717,7 +717,7 @@ export class ComAtprotoLabelNS { } } -export class ComAtprotoModerationNS { +export class ModerationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -736,7 +736,7 @@ export class ComAtprotoModerationNS { } } -export class ComAtprotoRepoNS { +export class RepoNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -832,7 +832,7 @@ export class ComAtprotoRepoNS { } } -export class ComAtprotoServerNS { +export class ServerNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1071,7 +1071,7 @@ export class ComAtprotoServerNS { } } -export class ComAtprotoSyncNS { +export class SyncNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1200,7 +1200,7 @@ export class ComAtprotoSyncNS { } } -export class ComAtprotoTempNS { +export class TempNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1276,37 +1276,37 @@ export class ComAtprotoTempNS { export class AppNS { _service: AtpServiceClient - bsky: AppBskyNS + bsky: BskyNS constructor(service: AtpServiceClient) { this._service = service - this.bsky = new AppBskyNS(service) + this.bsky = new BskyNS(service) } } -export class AppBskyNS { +export class BskyNS { _service: AtpServiceClient - actor: AppBskyActorNS - embed: AppBskyEmbedNS - feed: AppBskyFeedNS - graph: AppBskyGraphNS - notification: AppBskyNotificationNS - richtext: AppBskyRichtextNS - unspecced: AppBskyUnspeccedNS + actor: ActorNS + embed: EmbedNS + feed: FeedNS + graph: GraphNS + notification: NotificationNS + richtext: RichtextNS + unspecced: UnspeccedNS constructor(service: AtpServiceClient) { this._service = service - this.actor = new AppBskyActorNS(service) - this.embed = new AppBskyEmbedNS(service) - this.feed = new AppBskyFeedNS(service) - this.graph = new AppBskyGraphNS(service) - this.notification = new AppBskyNotificationNS(service) - this.richtext = new AppBskyRichtextNS(service) - this.unspecced = new AppBskyUnspeccedNS(service) + this.actor = new ActorNS(service) + this.embed = new EmbedNS(service) + this.feed = new FeedNS(service) + this.graph = new GraphNS(service) + this.notification = new NotificationNS(service) + this.richtext = new RichtextNS(service) + this.unspecced = new UnspeccedNS(service) } } -export class AppBskyActorNS { +export class ActorNS { _service: AtpServiceClient profile: ProfileRecord @@ -1454,7 +1454,7 @@ export class ProfileRecord { } } -export class AppBskyEmbedNS { +export class EmbedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -1462,7 +1462,7 @@ export class AppBskyEmbedNS { } } -export class AppBskyFeedNS { +export class FeedNS { _service: AtpServiceClient generator: GeneratorRecord like: LikeRecord @@ -1965,7 +1965,7 @@ export class ThreadgateRecord { } } -export class AppBskyGraphNS { +export class GraphNS { _service: AtpServiceClient block: BlockRecord follow: FollowRecord @@ -2440,7 +2440,7 @@ export class ListitemRecord { } } -export class AppBskyNotificationNS { +export class NotificationNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2492,7 +2492,7 @@ export class AppBskyNotificationNS { } } -export class AppBskyRichtextNS { +export class RichtextNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { @@ -2500,7 +2500,7 @@ export class AppBskyRichtextNS { } } -export class AppBskyUnspeccedNS { +export class UnspeccedNS { _service: AtpServiceClient constructor(service: AtpServiceClient) { diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 88b2ad43465..a46c726bcfe 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -167,39 +167,39 @@ export class Server { export class ComNS { _server: Server - atproto: ComAtprotoNS + atproto: AtprotoNS constructor(server: Server) { this._server = server - this.atproto = new ComAtprotoNS(server) + this.atproto = new AtprotoNS(server) } } -export class ComAtprotoNS { +export class AtprotoNS { _server: Server - admin: ComAtprotoAdminNS - identity: ComAtprotoIdentityNS - label: ComAtprotoLabelNS - moderation: ComAtprotoModerationNS - repo: ComAtprotoRepoNS - server: ComAtprotoServerNS - sync: ComAtprotoSyncNS - temp: ComAtprotoTempNS + admin: AdminNS + identity: IdentityNS + label: LabelNS + moderation: ModerationNS + repo: RepoNS + server: ServerNS + sync: SyncNS + temp: TempNS constructor(server: Server) { this._server = server - this.admin = new ComAtprotoAdminNS(server) - this.identity = new ComAtprotoIdentityNS(server) - this.label = new ComAtprotoLabelNS(server) - this.moderation = new ComAtprotoModerationNS(server) - this.repo = new ComAtprotoRepoNS(server) - this.server = new ComAtprotoServerNS(server) - this.sync = new ComAtprotoSyncNS(server) - this.temp = new ComAtprotoTempNS(server) + this.admin = new AdminNS(server) + this.identity = new IdentityNS(server) + this.label = new LabelNS(server) + this.moderation = new ModerationNS(server) + this.repo = new RepoNS(server) + this.server = new ServerNS(server) + this.sync = new SyncNS(server) + this.temp = new TempNS(server) } } -export class ComAtprotoAdminNS { +export class AdminNS { _server: Server constructor(server: Server) { @@ -460,7 +460,7 @@ export class ComAtprotoAdminNS { } } -export class ComAtprotoIdentityNS { +export class IdentityNS { _server: Server constructor(server: Server) { @@ -490,7 +490,7 @@ export class ComAtprotoIdentityNS { } } -export class ComAtprotoLabelNS { +export class LabelNS { _server: Server constructor(server: Server) { @@ -520,7 +520,7 @@ export class ComAtprotoLabelNS { } } -export class ComAtprotoModerationNS { +export class ModerationNS { _server: Server constructor(server: Server) { @@ -539,7 +539,7 @@ export class ComAtprotoModerationNS { } } -export class ComAtprotoRepoNS { +export class RepoNS { _server: Server constructor(server: Server) { @@ -635,7 +635,7 @@ export class ComAtprotoRepoNS { } } -export class ComAtprotoServerNS { +export class ServerNS { _server: Server constructor(server: Server) { @@ -874,7 +874,7 @@ export class ComAtprotoServerNS { } } -export class ComAtprotoSyncNS { +export class SyncNS { _server: Server constructor(server: Server) { @@ -1014,7 +1014,7 @@ export class ComAtprotoSyncNS { } } -export class ComAtprotoTempNS { +export class TempNS { _server: Server constructor(server: Server) { @@ -1090,37 +1090,37 @@ export class ComAtprotoTempNS { export class AppNS { _server: Server - bsky: AppBskyNS + bsky: BskyNS constructor(server: Server) { this._server = server - this.bsky = new AppBskyNS(server) + this.bsky = new BskyNS(server) } } -export class AppBskyNS { +export class BskyNS { _server: Server - actor: AppBskyActorNS - embed: AppBskyEmbedNS - feed: AppBskyFeedNS - graph: AppBskyGraphNS - notification: AppBskyNotificationNS - richtext: AppBskyRichtextNS - unspecced: AppBskyUnspeccedNS + actor: ActorNS + embed: EmbedNS + feed: FeedNS + graph: GraphNS + notification: NotificationNS + richtext: RichtextNS + unspecced: UnspeccedNS constructor(server: Server) { this._server = server - this.actor = new AppBskyActorNS(server) - this.embed = new AppBskyEmbedNS(server) - this.feed = new AppBskyFeedNS(server) - this.graph = new AppBskyGraphNS(server) - this.notification = new AppBskyNotificationNS(server) - this.richtext = new AppBskyRichtextNS(server) - this.unspecced = new AppBskyUnspeccedNS(server) + this.actor = new ActorNS(server) + this.embed = new EmbedNS(server) + this.feed = new FeedNS(server) + this.graph = new GraphNS(server) + this.notification = new NotificationNS(server) + this.richtext = new RichtextNS(server) + this.unspecced = new UnspeccedNS(server) } } -export class AppBskyActorNS { +export class ActorNS { _server: Server constructor(server: Server) { @@ -1205,7 +1205,7 @@ export class AppBskyActorNS { } } -export class AppBskyEmbedNS { +export class EmbedNS { _server: Server constructor(server: Server) { @@ -1213,7 +1213,7 @@ export class AppBskyEmbedNS { } } -export class AppBskyFeedNS { +export class FeedNS { _server: Server constructor(server: Server) { @@ -1397,7 +1397,7 @@ export class AppBskyFeedNS { } } -export class AppBskyGraphNS { +export class GraphNS { _server: Server constructor(server: Server) { @@ -1548,7 +1548,7 @@ export class AppBskyGraphNS { } } -export class AppBskyNotificationNS { +export class NotificationNS { _server: Server constructor(server: Server) { @@ -1600,7 +1600,7 @@ export class AppBskyNotificationNS { } } -export class AppBskyRichtextNS { +export class RichtextNS { _server: Server constructor(server: Server) { @@ -1608,7 +1608,7 @@ export class AppBskyRichtextNS { } } -export class AppBskyUnspeccedNS { +export class UnspeccedNS { _server: Server constructor(server: Server) { @@ -1682,13 +1682,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 88b2ad43465..a46c726bcfe 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -167,39 +167,39 @@ export class Server { export class ComNS { _server: Server - atproto: ComAtprotoNS + atproto: AtprotoNS constructor(server: Server) { this._server = server - this.atproto = new ComAtprotoNS(server) + this.atproto = new AtprotoNS(server) } } -export class ComAtprotoNS { +export class AtprotoNS { _server: Server - admin: ComAtprotoAdminNS - identity: ComAtprotoIdentityNS - label: ComAtprotoLabelNS - moderation: ComAtprotoModerationNS - repo: ComAtprotoRepoNS - server: ComAtprotoServerNS - sync: ComAtprotoSyncNS - temp: ComAtprotoTempNS + admin: AdminNS + identity: IdentityNS + label: LabelNS + moderation: ModerationNS + repo: RepoNS + server: ServerNS + sync: SyncNS + temp: TempNS constructor(server: Server) { this._server = server - this.admin = new ComAtprotoAdminNS(server) - this.identity = new ComAtprotoIdentityNS(server) - this.label = new ComAtprotoLabelNS(server) - this.moderation = new ComAtprotoModerationNS(server) - this.repo = new ComAtprotoRepoNS(server) - this.server = new ComAtprotoServerNS(server) - this.sync = new ComAtprotoSyncNS(server) - this.temp = new ComAtprotoTempNS(server) + this.admin = new AdminNS(server) + this.identity = new IdentityNS(server) + this.label = new LabelNS(server) + this.moderation = new ModerationNS(server) + this.repo = new RepoNS(server) + this.server = new ServerNS(server) + this.sync = new SyncNS(server) + this.temp = new TempNS(server) } } -export class ComAtprotoAdminNS { +export class AdminNS { _server: Server constructor(server: Server) { @@ -460,7 +460,7 @@ export class ComAtprotoAdminNS { } } -export class ComAtprotoIdentityNS { +export class IdentityNS { _server: Server constructor(server: Server) { @@ -490,7 +490,7 @@ export class ComAtprotoIdentityNS { } } -export class ComAtprotoLabelNS { +export class LabelNS { _server: Server constructor(server: Server) { @@ -520,7 +520,7 @@ export class ComAtprotoLabelNS { } } -export class ComAtprotoModerationNS { +export class ModerationNS { _server: Server constructor(server: Server) { @@ -539,7 +539,7 @@ export class ComAtprotoModerationNS { } } -export class ComAtprotoRepoNS { +export class RepoNS { _server: Server constructor(server: Server) { @@ -635,7 +635,7 @@ export class ComAtprotoRepoNS { } } -export class ComAtprotoServerNS { +export class ServerNS { _server: Server constructor(server: Server) { @@ -874,7 +874,7 @@ export class ComAtprotoServerNS { } } -export class ComAtprotoSyncNS { +export class SyncNS { _server: Server constructor(server: Server) { @@ -1014,7 +1014,7 @@ export class ComAtprotoSyncNS { } } -export class ComAtprotoTempNS { +export class TempNS { _server: Server constructor(server: Server) { @@ -1090,37 +1090,37 @@ export class ComAtprotoTempNS { export class AppNS { _server: Server - bsky: AppBskyNS + bsky: BskyNS constructor(server: Server) { this._server = server - this.bsky = new AppBskyNS(server) + this.bsky = new BskyNS(server) } } -export class AppBskyNS { +export class BskyNS { _server: Server - actor: AppBskyActorNS - embed: AppBskyEmbedNS - feed: AppBskyFeedNS - graph: AppBskyGraphNS - notification: AppBskyNotificationNS - richtext: AppBskyRichtextNS - unspecced: AppBskyUnspeccedNS + actor: ActorNS + embed: EmbedNS + feed: FeedNS + graph: GraphNS + notification: NotificationNS + richtext: RichtextNS + unspecced: UnspeccedNS constructor(server: Server) { this._server = server - this.actor = new AppBskyActorNS(server) - this.embed = new AppBskyEmbedNS(server) - this.feed = new AppBskyFeedNS(server) - this.graph = new AppBskyGraphNS(server) - this.notification = new AppBskyNotificationNS(server) - this.richtext = new AppBskyRichtextNS(server) - this.unspecced = new AppBskyUnspeccedNS(server) + this.actor = new ActorNS(server) + this.embed = new EmbedNS(server) + this.feed = new FeedNS(server) + this.graph = new GraphNS(server) + this.notification = new NotificationNS(server) + this.richtext = new RichtextNS(server) + this.unspecced = new UnspeccedNS(server) } } -export class AppBskyActorNS { +export class ActorNS { _server: Server constructor(server: Server) { @@ -1205,7 +1205,7 @@ export class AppBskyActorNS { } } -export class AppBskyEmbedNS { +export class EmbedNS { _server: Server constructor(server: Server) { @@ -1213,7 +1213,7 @@ export class AppBskyEmbedNS { } } -export class AppBskyFeedNS { +export class FeedNS { _server: Server constructor(server: Server) { @@ -1397,7 +1397,7 @@ export class AppBskyFeedNS { } } -export class AppBskyGraphNS { +export class GraphNS { _server: Server constructor(server: Server) { @@ -1548,7 +1548,7 @@ export class AppBskyGraphNS { } } -export class AppBskyNotificationNS { +export class NotificationNS { _server: Server constructor(server: Server) { @@ -1600,7 +1600,7 @@ export class AppBskyNotificationNS { } } -export class AppBskyRichtextNS { +export class RichtextNS { _server: Server constructor(server: Server) { @@ -1608,7 +1608,7 @@ export class AppBskyRichtextNS { } } -export class AppBskyUnspeccedNS { +export class UnspeccedNS { _server: Server constructor(server: Server) { @@ -1682,13 +1682,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } From a2aa60e98db4e5b796912419f6a5e1c2384e5630 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 20:00:50 -0600 Subject: [PATCH 068/135] tests --- .../api/com/atproto/temp/checkSignupQueue.ts | 2 +- packages/pds/src/signup-queue/index.ts | 3 + packages/pds/tests/signup-queue.test.ts | 226 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/pds/src/signup-queue/index.ts create mode 100644 packages/pds/tests/signup-queue.test.ts diff --git a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts index 6c6d201deff..cb58726b9a7 100644 --- a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts +++ b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts @@ -29,7 +29,7 @@ export default function (server: Server, ctx: AppContext) { let estimatedTimeMs: number | undefined if ( placeInQueue && - limiterStatus.disableSignups && + !limiterStatus.disableSignups && limiterStatus.accountsInPeriod > 0 ) { estimatedTimeMs = diff --git a/packages/pds/src/signup-queue/index.ts b/packages/pds/src/signup-queue/index.ts new file mode 100644 index 00000000000..10f307808ba --- /dev/null +++ b/packages/pds/src/signup-queue/index.ts @@ -0,0 +1,3 @@ +export * from './activator' +export * from './limiter' +export * from './util' diff --git a/packages/pds/tests/signup-queue.test.ts b/packages/pds/tests/signup-queue.test.ts new file mode 100644 index 00000000000..ff5f086c9f0 --- /dev/null +++ b/packages/pds/tests/signup-queue.test.ts @@ -0,0 +1,226 @@ +import { once } from 'events' +import AtpAgent, { ComAtprotoServerResetPassword } from '@atproto/api' +import * as crypto from '@atproto/crypto' +import { SeedClient, TestNetworkNoAppView } from '@atproto/dev-env' +import Mail from 'nodemailer/lib/mailer' +import { AppContext } from '../src' +import { + DISABLE_SIGNUPS_FLAG, + PERIOD_ALLOWANCE_FLAG, + PERIOD_MS_FLAG, +} from '../src/signup-queue' +import { DAY } from '@atproto/common' +import assert from 'assert' +import { create } from 'domain' + +describe('signup queue', () => { + let network: TestNetworkNoAppView + let ctx: AppContext + let agent: AtpAgent + let sc: SeedClient + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'signup_queue', + }) + ctx = network.pds.ctx + agent = network.pds.getClient() + sc = network.getSeedClient() + + await ctx.db.db + .insertInto('runtime_flag') + .values([ + { + name: DISABLE_SIGNUPS_FLAG, + value: 'false', + }, + { + name: PERIOD_ALLOWANCE_FLAG, + value: '2', + }, + { + name: PERIOD_MS_FLAG, + value: DAY.toString(), + }, + ]) + .execute() + await ctx.signupLimiter.refresh() + }) + + afterAll(async () => { + await network.close() + }) + + const createAccount = (name: string) => { + return sc.createAccount(name, { + handle: `${name}.test`, + email: `${name}@test.com`, + password: `${name}-pass`, + }) + } + + let deactivated: string + + it('does not activate accounts that exceed signups', async () => { + const one = await createAccount('one') + await ctx.signupLimiter.refresh() + const two = await createAccount('two') + await ctx.signupLimiter.refresh() + const three = await createAccount('three') + await ctx.signupLimiter.refresh() + await ctx.signupActivator.activateBatch() + deactivated = three.did + + const accounts = await ctx.db.db + .selectFrom('user_account') + .selectAll() + .where('did', 'in', [one.did, two.did, three.did]) + .execute() + const oneRow = accounts.find((row) => row.did === one.did) + assert(oneRow) + const twoRow = accounts.find((row) => row.did === two.did) + assert(twoRow) + const threeRow = accounts.find((row) => row.did === three.did) + assert(threeRow) + + expect(oneRow.activatedAt).toEqual(oneRow.createdAt) + expect(twoRow.activatedAt).toEqual(twoRow.createdAt) + expect(threeRow.activatedAt).toBeNull() + }) + + it('allows an account to check their status in the queue', async () => { + const four = await createAccount('four') + await ctx.signupLimiter.refresh() + await ctx.signupActivator.activateBatch() + + const res = await agent.api.com.atproto.temp.checkSignupQueue(undefined, { + headers: sc.getHeaders(four.did), + }) + expect(res.data.activated).toBe(false) + expect(res.data.placeInQueue).toBe(1) + expect(res.data.estimatedTimeMs).toBe(0.5 * DAY) + }) + + it('does not allow an account to perform other actions on their pds', async () => { + const five = await createAccount('five') + await ctx.signupLimiter.refresh() + await ctx.signupActivator.activateBatch() + + await agent.api + const attempt1 = agent.api.app.bsky.feed.getTimeline( + {}, + { + headers: sc.getHeaders(five.did), + }, + ) + await expect(attempt1).rejects.toThrow('Bad token scope') + + const attempt2 = agent.api.app.bsky.actor.getPreferences( + {}, + { + headers: sc.getHeaders(five.did), + }, + ) + await expect(attempt2).rejects.toThrow('Bad token scope') + + const attempt3 = agent.api.app.bsky.feed.post.create( + { repo: five.did }, + { + text: 'test', + createdAt: new Date().toISOString(), + }, + sc.getHeaders(five.did), + ) + await expect(attempt3).rejects.toThrow('Bad token scope') + }) + + it('returns a deactived access token on refresh as well', async () => { + const six = await createAccount('six') + await ctx.signupLimiter.refresh() + await ctx.signupActivator.activateBatch() + + const refreshed = await agent.api.com.atproto.server.refreshSession( + undefined, + { + headers: { authorization: `Bearer ${six.refreshJwt}` }, + }, + ) + + const attempt = agent.api.app.bsky.feed.getTimeline( + {}, + { + headers: { authorization: `Bearer ${refreshed.data.accessJwt}` }, + }, + ) + await expect(attempt).rejects.toThrow('Bad token scope') + }) + + it('automatically activates accounts when its their time', async () => { + await ctx.db.db + .updateTable('runtime_flag') + .set({ value: '100' }) + .where('name', '=', PERIOD_ALLOWANCE_FLAG) + .execute() + await ctx.signupLimiter.refresh() + await ctx.signupActivator.activateBatch() + + const res = await ctx.db.db + .selectFrom('user_account') + .selectAll() + .where('activatedAt', 'is', null) + .execute() + expect(res.length).toBe(0) + }) + + it('returns a working access token on refresh after activation', async () => { + const deactivatedRefreshToken = sc.accounts[deactivated].refreshJwt + const refreshed = await agent.api.com.atproto.server.refreshSession( + undefined, + { + headers: { authorization: `Bearer ${deactivatedRefreshToken}` }, + }, + ) + + await agent.api.app.bsky.actor.getPreferences( + {}, + { + headers: { authorization: `Bearer ${refreshed.data.accessJwt}` }, + }, + ) + }) + + it('disables and reenables signups entirely', async () => { + await ctx.db.db + .updateTable('runtime_flag') + .set({ value: 'true' }) + .where('name', '=', DISABLE_SIGNUPS_FLAG) + .execute() + await ctx.signupLimiter.refresh() + const seven = await createAccount('seven') + await ctx.signupActivator.activateBatch() + + const accountRowBefore = await ctx.db.db + .selectFrom('user_account') + .selectAll() + .where('did', '=', seven.did) + .executeTakeFirst() + assert(accountRowBefore) + expect(accountRowBefore.activatedAt).toBeNull() + + await ctx.db.db + .updateTable('runtime_flag') + .set({ value: 'false' }) + .where('name', '=', DISABLE_SIGNUPS_FLAG) + .execute() + await ctx.signupLimiter.refresh() + await ctx.signupActivator.activateBatch() + + const accountRowAfter = await ctx.db.db + .selectFrom('user_account') + .selectAll() + .where('did', '=', seven.did) + .executeTakeFirst() + assert(accountRowAfter) + expect(typeof accountRowAfter.activatedAt).toBe('string') + }) +}) From 071d0fcbb4ed14130df8bb06f9a8e5a221a253b3 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 23 Jan 2024 20:22:03 -0600 Subject: [PATCH 069/135] tidy test + dev-env mock --- packages/dev-env/src/mock/index.ts | 17 +++++++++++++++++ packages/pds/tests/signup-queue.test.ts | 6 +----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/dev-env/src/mock/index.ts b/packages/dev-env/src/mock/index.ts index 93b2f4717c4..acd02732ef4 100644 --- a/packages/dev-env/src/mock/index.ts +++ b/packages/dev-env/src/mock/index.ts @@ -347,6 +347,23 @@ export async function generateMockSetup(env: TestNetwork) { createdAt: date.next().value, }, ) + + // uncomment to enable signup limits + // the following parameters for instance, will allow 2 signups after minute + // await env.pds.ctx.db.db + // .insertInto('runtime_flag') + // .values([ + // { + // name: 'signup-limiter:periodMs', + // value: (1000 * 60).toString(), + // }, + // { + // name: 'signup-limiter:periodAllowance', + // value: '2', + // }, + // ]) + // .execute() + // await env.pds.ctx.signupLimiter.refresh() } function ucfirst(str: string): string { diff --git a/packages/pds/tests/signup-queue.test.ts b/packages/pds/tests/signup-queue.test.ts index ff5f086c9f0..c2d0f082373 100644 --- a/packages/pds/tests/signup-queue.test.ts +++ b/packages/pds/tests/signup-queue.test.ts @@ -1,8 +1,5 @@ -import { once } from 'events' -import AtpAgent, { ComAtprotoServerResetPassword } from '@atproto/api' -import * as crypto from '@atproto/crypto' +import AtpAgent from '@atproto/api' import { SeedClient, TestNetworkNoAppView } from '@atproto/dev-env' -import Mail from 'nodemailer/lib/mailer' import { AppContext } from '../src' import { DISABLE_SIGNUPS_FLAG, @@ -11,7 +8,6 @@ import { } from '../src/signup-queue' import { DAY } from '@atproto/common' import assert from 'assert' -import { create } from 'domain' describe('signup queue', () => { let network: TestNetworkNoAppView From 39e2956f131423129663cb5e7adf18a45f09b571 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Wed, 24 Jan 2024 09:03:18 -0600 Subject: [PATCH 070/135] Entryway: tagged suggestions (#2078) * proxy getTaggedSuggestions on entryway * rm unused err --- .../bsky/unspecced/getTaggedSuggestions.json | 42 ++++++++++++ packages/api/src/client/index.ts | 13 ++++ packages/api/src/client/lexicons.ts | 50 ++++++++++++++ .../bsky/unspecced/getTaggedSuggestions.ts | 55 ++++++++++++++++ packages/bsky/src/lexicon/index.ts | 14 +++- packages/bsky/src/lexicon/lexicons.ts | 50 ++++++++++++++ .../bsky/unspecced/getTaggedSuggestions.ts | 65 +++++++++++++++++++ .../bsky/unspecced/getTaggedSuggestions.ts | 44 +++++++++++++ .../pds/src/api/app/bsky/unspecced/index.ts | 2 + packages/pds/src/lexicon/index.ts | 14 +++- packages/pds/src/lexicon/lexicons.ts | 50 ++++++++++++++ .../bsky/unspecced/getTaggedSuggestions.ts | 65 +++++++++++++++++++ 12 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 lexicons/app/bsky/unspecced/getTaggedSuggestions.json create mode 100644 packages/api/src/client/types/app/bsky/unspecced/getTaggedSuggestions.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts create mode 100644 packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts diff --git a/lexicons/app/bsky/unspecced/getTaggedSuggestions.json b/lexicons/app/bsky/unspecced/getTaggedSuggestions.json new file mode 100644 index 00000000000..9fd98ffefb0 --- /dev/null +++ b/lexicons/app/bsky/unspecced/getTaggedSuggestions.json @@ -0,0 +1,42 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getTaggedSuggestions", + "defs": { + "main": { + "type": "query", + "description": "Get a list of suggestions (feeds and users) tagged with categories", + "parameters": { + "type": "params", + "properties": {} + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["suggestions"], + "properties": { + "suggestions": { + "type": "array", + "items": { + "type": "ref", + "ref": "#suggestion" + } + } + } + } + } + }, + "suggestion": { + "type": "object", + "required": ["tag", "subjectType", "subject"], + "properties": { + "tag": { "type": "string" }, + "subjectType": { + "type": "string", + "knownValues": ["actor", "feed"] + }, + "subject": { "type": "string", "format": "uri" } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index c996a63b4ea..2ff30e38151 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -147,6 +147,7 @@ import * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet' import * as AppBskyUnspeccedDefs from './types/app/bsky/unspecced/defs' import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +import * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions' import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton' import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecced/searchActorsSkeleton' import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' @@ -291,6 +292,7 @@ export * as AppBskyRichtextFacet from './types/app/bsky/richtext/facet' export * as AppBskyUnspeccedDefs from './types/app/bsky/unspecced/defs' export * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular' export * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +export * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions' export * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton' export * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecced/searchActorsSkeleton' export * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' @@ -2508,6 +2510,17 @@ export class UnspeccedNS { }) } + getTaggedSuggestions( + params?: AppBskyUnspeccedGetTaggedSuggestions.QueryParams, + opts?: AppBskyUnspeccedGetTaggedSuggestions.CallOptions, + ): Promise { + return this._service.xrpc + .call('app.bsky.unspecced.getTaggedSuggestions', params, undefined, opts) + .catch((e) => { + throw AppBskyUnspeccedGetTaggedSuggestions.toKnownErr(e) + }) + } + getTimelineSkeleton( params?: AppBskyUnspeccedGetTimelineSkeleton.QueryParams, opts?: AppBskyUnspeccedGetTimelineSkeleton.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 09a586338ea..86aaa2e8e19 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -7924,6 +7924,54 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetTaggedSuggestions: { + lexicon: 1, + id: 'app.bsky.unspecced.getTaggedSuggestions', + defs: { + main: { + type: 'query', + description: + 'Get a list of suggestions (feeds and users) tagged with categories', + parameters: { + type: 'params', + properties: {}, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['suggestions'], + properties: { + suggestions: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.getTaggedSuggestions#suggestion', + }, + }, + }, + }, + }, + }, + suggestion: { + type: 'object', + required: ['tag', 'subjectType', 'subject'], + properties: { + tag: { + type: 'string', + }, + subjectType: { + type: 'string', + knownValues: ['actor', 'feed'], + }, + subject: { + type: 'string', + format: 'uri', + }, + }, + }, + }, + }, AppBskyUnspeccedGetTimelineSkeleton: { lexicon: 1, id: 'app.bsky.unspecced.getTimelineSkeleton', @@ -8257,6 +8305,8 @@ export const ids = { AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', + AppBskyUnspeccedGetTaggedSuggestions: + 'app.bsky.unspecced.getTaggedSuggestions', AppBskyUnspeccedGetTimelineSkeleton: 'app.bsky.unspecced.getTimelineSkeleton', AppBskyUnspeccedSearchActorsSkeleton: 'app.bsky.unspecced.searchActorsSkeleton', diff --git a/packages/api/src/client/types/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/api/src/client/types/app/bsky/unspecced/getTaggedSuggestions.ts new file mode 100644 index 00000000000..a35e2411756 --- /dev/null +++ b/packages/api/src/client/types/app/bsky/unspecced/getTaggedSuggestions.ts @@ -0,0 +1,55 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + suggestions: Suggestion[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} + +export interface Suggestion { + tag: string + subjectType: 'actor' | 'feed' | (string & {}) + subject: string + [k: string]: unknown +} + +export function isSuggestion(v: unknown): v is Suggestion { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.unspecced.getTaggedSuggestions#suggestion' + ) +} + +export function validateSuggestion(v: unknown): ValidationResult { + return lexicons.validate( + 'app.bsky.unspecced.getTaggedSuggestions#suggestion', + v, + ) +} diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 917078e5bb0..4efd7f7837a 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -124,6 +124,7 @@ import * as AppBskyNotificationRegisterPush from './types/app/bsky/notification/ import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen' import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +import * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions' import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton' import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecced/searchActorsSkeleton' import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' @@ -1613,6 +1614,17 @@ export class UnspeccedNS { return this._server.xrpc.method(nsid, cfg) } + getTaggedSuggestions( + cfg: ConfigOf< + AV, + AppBskyUnspeccedGetTaggedSuggestions.Handler>, + AppBskyUnspeccedGetTaggedSuggestions.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.unspecced.getTaggedSuggestions' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getTimelineSkeleton( cfg: ConfigOf< AV, @@ -1658,13 +1670,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 09a586338ea..86aaa2e8e19 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -7924,6 +7924,54 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetTaggedSuggestions: { + lexicon: 1, + id: 'app.bsky.unspecced.getTaggedSuggestions', + defs: { + main: { + type: 'query', + description: + 'Get a list of suggestions (feeds and users) tagged with categories', + parameters: { + type: 'params', + properties: {}, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['suggestions'], + properties: { + suggestions: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.getTaggedSuggestions#suggestion', + }, + }, + }, + }, + }, + }, + suggestion: { + type: 'object', + required: ['tag', 'subjectType', 'subject'], + properties: { + tag: { + type: 'string', + }, + subjectType: { + type: 'string', + knownValues: ['actor', 'feed'], + }, + subject: { + type: 'string', + format: 'uri', + }, + }, + }, + }, + }, AppBskyUnspeccedGetTimelineSkeleton: { lexicon: 1, id: 'app.bsky.unspecced.getTimelineSkeleton', @@ -8257,6 +8305,8 @@ export const ids = { AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', + AppBskyUnspeccedGetTaggedSuggestions: + 'app.bsky.unspecced.getTaggedSuggestions', AppBskyUnspeccedGetTimelineSkeleton: 'app.bsky.unspecced.getTimelineSkeleton', AppBskyUnspeccedSearchActorsSkeleton: 'app.bsky.unspecced.searchActorsSkeleton', diff --git a/packages/bsky/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/bsky/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts new file mode 100644 index 00000000000..e6319c54b4e --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts @@ -0,0 +1,65 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + suggestions: Suggestion[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput + +export interface Suggestion { + tag: string + subjectType: 'actor' | 'feed' | (string & {}) + subject: string + [k: string]: unknown +} + +export function isSuggestion(v: unknown): v is Suggestion { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.unspecced.getTaggedSuggestions#suggestion' + ) +} + +export function validateSuggestion(v: unknown): ValidationResult { + return lexicons.validate( + 'app.bsky.unspecced.getTaggedSuggestions#suggestion', + v, + ) +} diff --git a/packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts new file mode 100644 index 00000000000..ff4050bbc5c --- /dev/null +++ b/packages/pds/src/api/app/bsky/unspecced/getTaggedSuggestions.ts @@ -0,0 +1,44 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { + authPassthru, + proxy, + proxyAppView, + resultPassthru, +} from '../../../proxy' + +// THIS IS A TEMPORARY UNSPECCED ROUTE +export default function (server: Server, ctx: AppContext) { + server.app.bsky.unspecced.getTaggedSuggestions({ + auth: ctx.authVerifier.access, + handler: async ({ auth, params, req }) => { + const proxied = await proxy( + ctx, + auth.credentials.audience, + async (agent) => { + const result = + await agent.api.app.bsky.unspecced.getTaggedSuggestions( + params, + authPassthru(req), + ) + return resultPassthru(result) + }, + ) + if (proxied !== null) { + return proxied + } + + const requester = auth.credentials.did + const res = await proxyAppView(ctx, async (agent) => + agent.api.app.bsky.unspecced.getTaggedSuggestions( + params, + await ctx.appviewAuthHeaders(requester), + ), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +} diff --git a/packages/pds/src/api/app/bsky/unspecced/index.ts b/packages/pds/src/api/app/bsky/unspecced/index.ts index 6951400863d..7f404c76d48 100644 --- a/packages/pds/src/api/app/bsky/unspecced/index.ts +++ b/packages/pds/src/api/app/bsky/unspecced/index.ts @@ -2,9 +2,11 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import getPopular from './getPopular' import getPopularFeedGenerators from './getPopularFeedGenerators' +import getTaggedSuggestions from './getTaggedSuggestions' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { getPopular(server, ctx) getPopularFeedGenerators(server, ctx) + getTaggedSuggestions(server, ctx) } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 917078e5bb0..4efd7f7837a 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -124,6 +124,7 @@ import * as AppBskyNotificationRegisterPush from './types/app/bsky/notification/ import * as AppBskyNotificationUpdateSeen from './types/app/bsky/notification/updateSeen' import * as AppBskyUnspeccedGetPopular from './types/app/bsky/unspecced/getPopular' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators' +import * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions' import * as AppBskyUnspeccedGetTimelineSkeleton from './types/app/bsky/unspecced/getTimelineSkeleton' import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecced/searchActorsSkeleton' import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' @@ -1613,6 +1614,17 @@ export class UnspeccedNS { return this._server.xrpc.method(nsid, cfg) } + getTaggedSuggestions( + cfg: ConfigOf< + AV, + AppBskyUnspeccedGetTaggedSuggestions.Handler>, + AppBskyUnspeccedGetTaggedSuggestions.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.unspecced.getTaggedSuggestions' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getTimelineSkeleton( cfg: ConfigOf< AV, @@ -1658,13 +1670,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 09a586338ea..86aaa2e8e19 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -7924,6 +7924,54 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetTaggedSuggestions: { + lexicon: 1, + id: 'app.bsky.unspecced.getTaggedSuggestions', + defs: { + main: { + type: 'query', + description: + 'Get a list of suggestions (feeds and users) tagged with categories', + parameters: { + type: 'params', + properties: {}, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['suggestions'], + properties: { + suggestions: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.unspecced.getTaggedSuggestions#suggestion', + }, + }, + }, + }, + }, + }, + suggestion: { + type: 'object', + required: ['tag', 'subjectType', 'subject'], + properties: { + tag: { + type: 'string', + }, + subjectType: { + type: 'string', + knownValues: ['actor', 'feed'], + }, + subject: { + type: 'string', + format: 'uri', + }, + }, + }, + }, + }, AppBskyUnspeccedGetTimelineSkeleton: { lexicon: 1, id: 'app.bsky.unspecced.getTimelineSkeleton', @@ -8257,6 +8305,8 @@ export const ids = { AppBskyUnspeccedGetPopular: 'app.bsky.unspecced.getPopular', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', + AppBskyUnspeccedGetTaggedSuggestions: + 'app.bsky.unspecced.getTaggedSuggestions', AppBskyUnspeccedGetTimelineSkeleton: 'app.bsky.unspecced.getTimelineSkeleton', AppBskyUnspeccedSearchActorsSkeleton: 'app.bsky.unspecced.searchActorsSkeleton', diff --git a/packages/pds/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/pds/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts new file mode 100644 index 00000000000..e6319c54b4e --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts @@ -0,0 +1,65 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + suggestions: Suggestion[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput + +export interface Suggestion { + tag: string + subjectType: 'actor' | 'feed' | (string & {}) + subject: string + [k: string]: unknown +} + +export function isSuggestion(v: unknown): v is Suggestion { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.bsky.unspecced.getTaggedSuggestions#suggestion' + ) +} + +export function validateSuggestion(v: unknown): ValidationResult { + return lexicons.validate( + 'app.bsky.unspecced.getTaggedSuggestions#suggestion', + v, + ) +} From a3cf3a3adb03f6fd8c074147a5e508a817a4a983 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 24 Jan 2024 12:46:14 -0600 Subject: [PATCH 071/135] update error language --- packages/pds/src/api/com/atproto/server/createAccount.ts | 4 ++-- packages/pds/tests/phone-verification.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 5786ff92868..1d94f876328 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -44,12 +44,12 @@ export default function (server: Server, ctx: AppContext) { if (ctx.cfg.phoneVerification.required && ctx.twilio) { if (!input.body.verificationPhone) { throw new InvalidRequestError( - 'Phone number verification is required on this server and none was provided.', + `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, 'InvalidPhoneVerification', ) } else if (!input.body.verificationCode) { throw new InvalidRequestError( - 'Phone number verification is required on this server and none was provided.', + `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, 'InvalidPhoneVerification', ) } diff --git a/packages/pds/tests/phone-verification.test.ts b/packages/pds/tests/phone-verification.test.ts index 525845309fb..a203a07ccf9 100644 --- a/packages/pds/tests/phone-verification.test.ts +++ b/packages/pds/tests/phone-verification.test.ts @@ -116,7 +116,7 @@ describe('phone verification', () => { it('does not allow signup with out a code', async () => { const attempt = createAccountWithCode() await expect(attempt).rejects.toThrow( - 'Phone number verification is required on this server and none was provided.', + `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, ) }) @@ -125,11 +125,11 @@ describe('phone verification', () => { const bobCode = await requestCode(bobNumber) const attempt = createAccountWithCode(undefined, bobCode) await expect(attempt).rejects.toThrow( - 'Phone number verification is required on this server and none was provided.', + `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, ) const attempt2 = createAccountWithCode(bobNumber, undefined) await expect(attempt2).rejects.toThrow( - 'Phone number verification is required on this server and none was provided.', + `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, ) }) From b18b766182b2d942b238dc9e85c5022d74d593f8 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 24 Jan 2024 18:10:34 -0600 Subject: [PATCH 072/135] bugfix twilio --- .../api/com/atproto/server/createAccount.ts | 34 ++++++++++++++----- packages/pds/src/logger.ts | 1 + packages/pds/src/twilio.ts | 7 ++-- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 1d94f876328..b78b358fb4c 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -37,6 +37,8 @@ export default function (server: Server, ctx: AppContext) { ? await validateInputsForPdsViaEntryway(ctx, input.body) : await validateInputsForPdsViaUser(ctx, input.body) + await ensureUnusedHandleAndEmail(ctx.db, handle, email) + const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) @@ -72,13 +74,6 @@ export default function (server: Server, ctx: AppContext) { const actorTxn = ctx.services.account(dbTxn) const repoTxn = ctx.services.repo(dbTxn) - // it's a bit goofy that we run this logic twice, - // but we run it once for a sanity check before doing scrypt & plc ops - // & a second time for locking + integrity check - if (ctx.cfg.invites.required && inviteCode) { - await ensureCodeIsAvailable(dbTxn, inviteCode, true) - } - // Register user before going out to PLC to get a real did try { await actorTxn.registerUser({ @@ -238,7 +233,8 @@ const validateInputsForPdsViaUser = async ( ctx: AppContext, input: CreateAccountInput, ) => { - const { email, password, inviteCode } = input + const { password, inviteCode } = input + const email = input.email?.toLowerCase() if (input.plcOp) { throw new InvalidRequestError('Unsupported input: "plcOp"') } @@ -269,6 +265,8 @@ const validateInputsForPdsViaUser = async ( did: input.did, }) + await ensureUnusedHandleAndEmail(ctx.db, handle, email) + // check that the invite code still has uses if (ctx.cfg.invites.required && inviteCode) { await ensureCodeIsAvailable(ctx.db, inviteCode) @@ -470,6 +468,26 @@ const reserveSigningKey = async (ctx: AppContext, host: string) => { } } +const ensureUnusedHandleAndEmail = async ( + db: Database, + handle: string, + email: string, +) => { + const res = await db.db + .selectFrom('user_account') + .innerJoin('did_handle', 'did_handle.did', 'user_account.did') + .select(['did_handle.handle', 'user_account.email']) + .where('user_account.email', '=', email) + .where('did_handle.handle', '=', handle) + .executeTakeFirst() + if (!res) return + if (res.email === email) { + throw new InvalidRequestError(`Email already taken: ${email}`) + } else { + throw new InvalidRequestError(`Handle already taken: ${handle}`) + } +} + const randomIndexByWeight = (weights) => { let sum = 0 const cumulative = weights.map((weight) => { diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index e8b663b567f..5e93f840fdd 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -11,6 +11,7 @@ export const seqLogger = subsystemLogger('pds:sequencer') export const mailerLogger = subsystemLogger('pds:mailer') export const labelerLogger = subsystemLogger('pds:labler') export const crawlerLogger = subsystemLogger('pds:crawler') +export const twilioLogger = subsystemLogger('pds:twilio') export const httpLogger = subsystemLogger('pds') export const loggerMiddleware = pinoHttp({ diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index a2d0846e396..113c9e18245 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -1,5 +1,6 @@ import { InvalidRequestError, UpstreamFailureError } from '@atproto/xrpc-server' import twilio from 'twilio' +import { twilioLogger as log } from './logger' type Opts = { accountSid: string @@ -20,7 +21,7 @@ export class TwilioClient { } normalizePhoneNumber(phoneNumber: string) { - let normalized = phoneNumber.replaceAll(/\(|\)|-| /g, '') + let normalized = phoneNumber.trim().replaceAll(/\(|\)|-| /g, '') if (!normalized.startsWith('+')) { if (normalized.length === 10) { normalized = '+1' + normalized @@ -43,6 +44,7 @@ export class TwilioClient { channel: 'sms', }) } catch (err) { + log.error({ err, phoneNumber }, 'error sending twilio code') throw new UpstreamFailureError('Could not send verification text') } } @@ -55,7 +57,8 @@ export class TwilioClient { }) return res.status === 'approved' } catch (err) { - throw new UpstreamFailureError('Could not send verification text') + log.error({ err, phoneNumber, code }, 'error verifying twilio code') + throw new UpstreamFailureError('Could not verify code. Please try again') } } } From f9e51aa418301afaf5f7e1de13b3189393893175 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 24 Jan 2024 18:19:57 -0600 Subject: [PATCH 073/135] patch up some tests --- packages/pds/tests/account.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts index f157380a1c1..c6e127f7162 100644 --- a/packages/pds/tests/account.test.ts +++ b/packages/pds/tests/account.test.ts @@ -169,7 +169,7 @@ describe('account', () => { const userKey = await crypto.Secp256k1Keypair.create() const baseDidInfo = { signingKey: ctx.repoSigningKey.did(), - handle: 'byo-did.test', + handle: 'byo-did2.test', rotationKeys: [ userKey.did(), ctx.cfg.identity.recoveryDidKey ?? '', @@ -179,9 +179,9 @@ describe('account', () => { signer: userKey, } const baseAccntInfo = { - email: 'byo-did@test.com', - handle: 'byo-did.test', - password: 'byo-did-pass', + email: 'byo-did2@test.com', + handle: 'byo-did2.test', + password: 'byo-did2-pass', } const did1 = await ctx.plcClient.createDid({ @@ -304,7 +304,7 @@ describe('account', () => { handle: 'carol.test', password, }), - ).rejects.toThrow('Email already taken: BOB@TEST.COM') + ).rejects.toThrow('Email already taken: bob@test.com') await expect( agent.api.com.atproto.server.createAccount({ From c7ba6778f8634b7d1470da9050438b6bedfc962c Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 24 Jan 2024 18:24:50 -0600 Subject: [PATCH 074/135] patch more tests --- packages/pds/src/api/com/atproto/server/createAccount.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index b78b358fb4c..8b58f3dc6b9 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -110,6 +110,7 @@ export default function (server: Server, ctx: AppContext) { // insert invite code use if (ctx.cfg.invites.required && inviteCode) { + await ensureCodeIsAvailable(dbTxn, inviteCode, true) await dbTxn.db .insertInto('invite_code_use') .values({ From 9a1fe0ae340e38a9bf34a4fa942679492356d4f1 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 24 Jan 2024 18:37:02 -0600 Subject: [PATCH 075/135] fix query --- packages/pds/src/api/com/atproto/server/createAccount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 8b58f3dc6b9..929654e33b0 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -479,7 +479,7 @@ const ensureUnusedHandleAndEmail = async ( .innerJoin('did_handle', 'did_handle.did', 'user_account.did') .select(['did_handle.handle', 'user_account.email']) .where('user_account.email', '=', email) - .where('did_handle.handle', '=', handle) + .orWhere('did_handle.handle', '=', handle) .executeTakeFirst() if (!res) return if (res.email === email) { From 65c7738fbd5695ad9921373e3602e0e62264fc19 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 24 Jan 2024 16:50:45 -0800 Subject: [PATCH 076/135] Expose refreshSession on the agent --- packages/api/src/agent.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index de80c9de07d..c6cc699cb0c 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -214,7 +214,7 @@ export class AtpAgent { // handle session-refreshes as needed if (isErrorResponse(res, ['ExpiredToken']) && this.session?.refreshJwt) { // attempt refresh - await this._refreshSession() + await this.refreshSession() // resend the request with the new access token res = await AtpAgent.fetch( @@ -233,7 +233,7 @@ export class AtpAgent { * - Wraps the actual implementation in a promise-guard to ensure only * one refresh is attempted at a time. */ - private async _refreshSession() { + async refreshSession() { if (this._refreshSessionPromise) { return this._refreshSessionPromise } From 52d5720171cc738590420a04051884c95c06e1e4 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 24 Jan 2024 19:08:57 -0600 Subject: [PATCH 077/135] couple more routes for deactivated accounts --- packages/pds/src/api/app/bsky/notification/registerPush.ts | 2 +- packages/pds/src/api/com/atproto/server/getSession.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/api/app/bsky/notification/registerPush.ts b/packages/pds/src/api/app/bsky/notification/registerPush.ts index 97fd40a8b7b..6d4174a2202 100644 --- a/packages/pds/src/api/app/bsky/notification/registerPush.ts +++ b/packages/pds/src/api/app/bsky/notification/registerPush.ts @@ -8,7 +8,7 @@ import { authPassthru, proxy, proxyAppView } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.notification.registerPush({ - auth: ctx.authVerifier.access, + auth: ctx.authVerifier.accessDeactived, handler: async ({ auth, input, req }) => { const proxied = await proxy( ctx, diff --git a/packages/pds/src/api/com/atproto/server/getSession.ts b/packages/pds/src/api/com/atproto/server/getSession.ts index e798c48a1b9..312274eeea9 100644 --- a/packages/pds/src/api/com/atproto/server/getSession.ts +++ b/packages/pds/src/api/com/atproto/server/getSession.ts @@ -5,7 +5,7 @@ import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.getSession({ - auth: ctx.authVerifier.access, + auth: ctx.authVerifier.accessDeactived, handler: async ({ auth }) => { const did = auth.credentials.did const [account, didDoc] = await Promise.all([ From 48b07312bb247ab68e793996703c1c7fd596bf5d Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 24 Jan 2024 20:27:48 -0600 Subject: [PATCH 078/135] typo --- packages/pds/src/auth-verifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 647ef359e42..7640ce0d2f3 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -29,7 +29,7 @@ export enum AuthScope { Access = 'com.atproto.access', Refresh = 'com.atproto.refresh', AppPass = 'com.atproto.appPass', - Deactivated = 'com.atproto.deactived', + Deactivated = 'com.atproto.deactivated', } export enum RoleStatus { From 5d5d7bce7db1e53c447b1b3acfca08519ca1abef Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 25 Jan 2024 11:15:43 -0600 Subject: [PATCH 079/135] tidy account existence check --- .../api/com/atproto/server/createAccount.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 929654e33b0..1fda28e0266 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -11,7 +11,10 @@ import * as scrypt from '../../../../db/scrypt' import { Server } from '../../../../lexicon' import { InputSchema as CreateAccountInput } from '../../../../lexicon/types/com/atproto/server/createAccount' import { countAll } from '../../../../db/util' -import { UserAlreadyExistsError } from '../../../../services/account' +import { + AccountService, + UserAlreadyExistsError, +} from '../../../../services/account' import AppContext from '../../../../context' import Database from '../../../../db' import { didDocForSession } from './util' @@ -37,8 +40,6 @@ export default function (server: Server, ctx: AppContext) { ? await validateInputsForPdsViaEntryway(ctx, input.body) : await validateInputsForPdsViaUser(ctx, input.body) - await ensureUnusedHandleAndEmail(ctx.db, handle, email) - const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) @@ -266,7 +267,7 @@ const validateInputsForPdsViaUser = async ( did: input.did, }) - await ensureUnusedHandleAndEmail(ctx.db, handle, email) + await ensureUnusedHandleAndEmail(ctx.services.account(ctx.db), handle, email) // check that the invite code still has uses if (ctx.cfg.invites.required && inviteCode) { @@ -470,21 +471,17 @@ const reserveSigningKey = async (ctx: AppContext, host: string) => { } const ensureUnusedHandleAndEmail = async ( - db: Database, + accountSrvc: AccountService, handle: string, email: string, ) => { - const res = await db.db - .selectFrom('user_account') - .innerJoin('did_handle', 'did_handle.did', 'user_account.did') - .select(['did_handle.handle', 'user_account.email']) - .where('user_account.email', '=', email) - .orWhere('did_handle.handle', '=', handle) - .executeTakeFirst() - if (!res) return - if (res.email === email) { + const [byHandle, byEmail] = await Promise.all([ + accountSrvc.getAccount(handle, true), + accountSrvc.getAccountByEmail(email, true), + ]) + if (byEmail) { throw new InvalidRequestError(`Email already taken: ${email}`) - } else { + } else if (byHandle) { throw new InvalidRequestError(`Handle already taken: ${handle}`) } } From e8b47b5356483bdb6e4c013dd9db77ec77c390db Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 25 Jan 2024 13:18:41 -0600 Subject: [PATCH 080/135] improve error handling --- packages/pds/src/twilio.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index 113c9e18245..3fb5f77ce7e 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -45,7 +45,18 @@ export class TwilioClient { }) } catch (err) { log.error({ err, phoneNumber }, 'error sending twilio code') - throw new UpstreamFailureError('Could not send verification text') + const code = typeof err === 'object' ? err?.['code'] : undefined + if (code === 60200) { + throw new InvalidRequestError( + 'Could not send verification text: invalid phone number', + ) + } else if (code === 60220) { + throw new InvalidRequestError( + `We're sorry, we're not currently able to send verification messages to China. We're working with our providers to solve this as quickly as possible.`, + ) + } else { + throw new UpstreamFailureError('Could not send verification text') + } } } From 021fa9adb5f30a33b37a6effb220ef7fefdb665c Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 25 Jan 2024 13:47:35 -0600 Subject: [PATCH 081/135] handle one more error --- packages/pds/src/twilio.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/twilio.ts b/packages/pds/src/twilio.ts index 3fb5f77ce7e..eec698865a3 100644 --- a/packages/pds/src/twilio.ts +++ b/packages/pds/src/twilio.ts @@ -50,9 +50,9 @@ export class TwilioClient { throw new InvalidRequestError( 'Could not send verification text: invalid phone number', ) - } else if (code === 60220) { + } else if (code === 60605 || code === 60220) { throw new InvalidRequestError( - `We're sorry, we're not currently able to send verification messages to China. We're working with our providers to solve this as quickly as possible.`, + `We're sorry, we're not currently able to send verification messages to your country. We're working with our providers to solve this as quickly as possible.`, ) } else { throw new UpstreamFailureError('Could not send verification text') From 49ced7cf20973390a2d2c91396b429102fb7037d Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 29 Jan 2024 16:35:14 -0600 Subject: [PATCH 082/135] dont build branch --- .github/workflows/build-and-push-pds-aws.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 7589f56769a..097f782d88e 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,6 @@ on: push: branches: - main - - entryway-twilio env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From efb613a997a061574a259fa4783c5933a36c1c27 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 10:51:47 -0600 Subject: [PATCH 083/135] email template --- packages/pds/src/mailer/index.ts | 11 + .../mailer/templates/account-activated.hbs | 298 ++++++++++++++++++ packages/pds/src/signup-queue/notifier.ts | 11 + 3 files changed, 320 insertions(+) create mode 100644 packages/pds/src/mailer/templates/account-activated.hbs create mode 100644 packages/pds/src/signup-queue/notifier.ts diff --git a/packages/pds/src/mailer/index.ts b/packages/pds/src/mailer/index.ts index 92ce8a88c83..3840d1789af 100644 --- a/packages/pds/src/mailer/index.ts +++ b/packages/pds/src/mailer/index.ts @@ -26,6 +26,7 @@ export class ServerMailer { deleteAccount: this.compile('delete-account'), confirmEmail: this.compile('confirm-email'), updateEmail: this.compile('update-email'), + accountActivated: this.compile('account-activated'), } } @@ -65,6 +66,16 @@ export class ServerMailer { }) } + async sendAccountActivated( + params: { handle: string }, + mailOpts: Mail.Options, + ) { + return this.sendTemplate('accountActivated', params, { + subject: 'Your Bluesky Account is Activated!', + ...mailOpts, + }) + } + private async sendTemplate(templateName, params, mailOpts: Mail.Options) { const html = this.templates[templateName]({ ...params, diff --git a/packages/pds/src/mailer/templates/account-activated.hbs b/packages/pds/src/mailer/templates/account-activated.hbs new file mode 100644 index 00000000000..a037d6e5d01 --- /dev/null +++ b/packages/pds/src/mailer/templates/account-activated.hbs @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/pds/src/signup-queue/notifier.ts b/packages/pds/src/signup-queue/notifier.ts new file mode 100644 index 00000000000..cd153a8e91e --- /dev/null +++ b/packages/pds/src/signup-queue/notifier.ts @@ -0,0 +1,11 @@ +import { ServerMailer } from '../mailer' + +export class ActivationNotifier { + constructor(public mailer: ServerMailer) {} + + async sendEmail(email: string, handle: string) { + await this.mailer.sendAccountActivated({ handle }, { to: email }) + } + + async sendPushNotif(email: string) {} +} From d79bafffa4a9367456b89752f35e67d70bdfa3fc Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 11:24:22 -0600 Subject: [PATCH 084/135] hooking up courier --- packages/pds/buf.gen.yaml | 12 + packages/pds/package.json | 9 +- packages/pds/proto/courier.proto | 56 ++++ packages/pds/src/courier.ts | 41 +++ packages/pds/src/proto/courier_connect.ts | 44 +++ packages/pds/src/proto/courier_pb.ts | 337 ++++++++++++++++++++++ packages/pds/src/signup-queue/notifier.ts | 23 +- pnpm-lock.yaml | 181 ++++++++++++ 8 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 packages/pds/buf.gen.yaml create mode 100644 packages/pds/proto/courier.proto create mode 100644 packages/pds/src/courier.ts create mode 100644 packages/pds/src/proto/courier_connect.ts create mode 100644 packages/pds/src/proto/courier_pb.ts diff --git a/packages/pds/buf.gen.yaml b/packages/pds/buf.gen.yaml new file mode 100644 index 00000000000..29f9668b5b8 --- /dev/null +++ b/packages/pds/buf.gen.yaml @@ -0,0 +1,12 @@ +version: v1 +plugins: + - plugin: es + opt: + - target=ts + - import_extension=.ts + out: src/proto + - plugin: connect-es + opt: + - target=ts + - import_extension=.ts + out: src/proto \ No newline at end of file diff --git a/packages/pds/package.json b/packages/pds/package.json index e575d094400..ce6e617607f 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -29,7 +29,8 @@ "test:sqlite": "jest --testPathIgnorePatterns /tests/proxied/*", "test:log": "tail -50 test.log | pino-pretty", "test:updateSnapshot": "jest --updateSnapshot", - "migration:create": "ts-node ./bin/migration-create.ts" + "migration:create": "ts-node ./bin/migration-create.ts", + "buf:gen": "buf generate ./proto" }, "dependencies": { "@atproto/api": "workspace:^", @@ -42,6 +43,9 @@ "@atproto/syntax": "workspace:^", "@atproto/xrpc": "workspace:^", "@atproto/xrpc-server": "workspace:^", + "@bufbuild/protobuf": "^1.5.0", + "@connectrpc/connect": "^1.1.4", + "@connectrpc/connect-node": "^1.1.4", "@did-plc/lib": "^0.0.1", "better-sqlite3": "^7.6.2", "bytes": "^3.1.2", @@ -77,6 +81,9 @@ "@atproto/bsky": "workspace:^", "@atproto/dev-env": "workspace:^", "@atproto/lex-cli": "workspace:^", + "@bufbuild/buf": "^1.28.1", + "@bufbuild/protoc-gen-es": "^1.5.0", + "@connectrpc/protoc-gen-connect-es": "^1.1.4", "@did-plc/server": "^0.0.1", "@types/cors": "^2.8.12", "@types/disposable-email": "^0.2.0", diff --git a/packages/pds/proto/courier.proto b/packages/pds/proto/courier.proto new file mode 100644 index 00000000000..7e46d4d652f --- /dev/null +++ b/packages/pds/proto/courier.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package courier; +option go_package = "./;courier"; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +// +// Messages +// + +// Ping +message PingRequest {} +message PingResponse {} + +// Notifications + +enum AppPlatform { + APP_PLATFORM_UNSPECIFIED = 0; + APP_PLATFORM_IOS = 1; + APP_PLATFORM_ANDROID = 2; + APP_PLATFORM_WEB = 3; +} + +message Notification { + string id = 1; + string recipient_did = 2; + string title = 3; + string message = 4; + string collapse_key = 5; + bool always_deliver = 6; + google.protobuf.Timestamp timestamp = 7; + google.protobuf.Struct additional = 8; +} + +message PushNotificationsRequest { + repeated Notification notifications = 1; +} + +message PushNotificationsResponse {} + +message RegisterDeviceTokenRequest { + string did = 1; + string token = 2; + string app_id = 3; + AppPlatform platform = 4; +} + +message RegisterDeviceTokenResponse {} + +service Service { + rpc Ping(PingRequest) returns (PingResponse); + rpc PushNotifications(PushNotificationsRequest) returns (PushNotificationsResponse); + rpc RegisterDeviceToken(RegisterDeviceTokenRequest) returns (RegisterDeviceTokenResponse); +} \ No newline at end of file diff --git a/packages/pds/src/courier.ts b/packages/pds/src/courier.ts new file mode 100644 index 00000000000..aeb095898f6 --- /dev/null +++ b/packages/pds/src/courier.ts @@ -0,0 +1,41 @@ +import { Service } from './proto/courier_connect' +import { + Code, + ConnectError, + PromiseClient, + createPromiseClient, + Interceptor, +} from '@connectrpc/connect' +import { + createConnectTransport, + ConnectTransportOptions, +} from '@connectrpc/connect-node' + +export type CourierClient = PromiseClient + +export const createCourierClient = ( + opts: ConnectTransportOptions, +): CourierClient => { + const transport = createConnectTransport(opts) + return createPromiseClient(Service, transport) +} + +export { Code } + +export const isCourierError = ( + err: unknown, + code?: Code, +): err is ConnectError => { + if (err instanceof ConnectError) { + return !code || err.code === code + } + return false +} + +export const authWithApiKey = + (apiKey: string): Interceptor => + (next) => + (req) => { + req.header.set('authorization', `Bearer ${apiKey}`) + return next(req) + } diff --git a/packages/pds/src/proto/courier_connect.ts b/packages/pds/src/proto/courier_connect.ts new file mode 100644 index 00000000000..cdf50cca128 --- /dev/null +++ b/packages/pds/src/proto/courier_connect.ts @@ -0,0 +1,44 @@ +// @generated by protoc-gen-connect-es v1.3.0 with parameter "target=ts,import_extension=.ts" +// @generated from file courier.proto (package courier, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { PingRequest, PingResponse, PushNotificationsRequest, PushNotificationsResponse, RegisterDeviceTokenRequest, RegisterDeviceTokenResponse } from "./courier_pb.ts"; +import { MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service courier.Service + */ +export const Service = { + typeName: "courier.Service", + methods: { + /** + * @generated from rpc courier.Service.Ping + */ + ping: { + name: "Ping", + I: PingRequest, + O: PingResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc courier.Service.PushNotifications + */ + pushNotifications: { + name: "PushNotifications", + I: PushNotificationsRequest, + O: PushNotificationsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc courier.Service.RegisterDeviceToken + */ + registerDeviceToken: { + name: "RegisterDeviceToken", + I: RegisterDeviceTokenRequest, + O: RegisterDeviceTokenResponse, + kind: MethodKind.Unary, + }, + } +} as const; + diff --git a/packages/pds/src/proto/courier_pb.ts b/packages/pds/src/proto/courier_pb.ts new file mode 100644 index 00000000000..3d413c0a3ff --- /dev/null +++ b/packages/pds/src/proto/courier_pb.ts @@ -0,0 +1,337 @@ +// @generated by protoc-gen-es v1.7.1 with parameter "target=ts,import_extension=.ts" +// @generated from file courier.proto (package courier, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3, Struct, Timestamp } from "@bufbuild/protobuf"; + +/** + * @generated from enum courier.AppPlatform + */ +export enum AppPlatform { + /** + * @generated from enum value: APP_PLATFORM_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: APP_PLATFORM_IOS = 1; + */ + IOS = 1, + + /** + * @generated from enum value: APP_PLATFORM_ANDROID = 2; + */ + ANDROID = 2, + + /** + * @generated from enum value: APP_PLATFORM_WEB = 3; + */ + WEB = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(AppPlatform) +proto3.util.setEnumType(AppPlatform, "courier.AppPlatform", [ + { no: 0, name: "APP_PLATFORM_UNSPECIFIED" }, + { no: 1, name: "APP_PLATFORM_IOS" }, + { no: 2, name: "APP_PLATFORM_ANDROID" }, + { no: 3, name: "APP_PLATFORM_WEB" }, +]); + +/** + * Ping + * + * @generated from message courier.PingRequest + */ +export class PingRequest extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "courier.PingRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): PingRequest { + return new PingRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): PingRequest { + return new PingRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): PingRequest { + return new PingRequest().fromJsonString(jsonString, options); + } + + static equals(a: PingRequest | PlainMessage | undefined, b: PingRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(PingRequest, a, b); + } +} + +/** + * @generated from message courier.PingResponse + */ +export class PingResponse extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "courier.PingResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): PingResponse { + return new PingResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): PingResponse { + return new PingResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): PingResponse { + return new PingResponse().fromJsonString(jsonString, options); + } + + static equals(a: PingResponse | PlainMessage | undefined, b: PingResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(PingResponse, a, b); + } +} + +/** + * @generated from message courier.Notification + */ +export class Notification extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string recipient_did = 2; + */ + recipientDid = ""; + + /** + * @generated from field: string title = 3; + */ + title = ""; + + /** + * @generated from field: string message = 4; + */ + message = ""; + + /** + * @generated from field: string collapse_key = 5; + */ + collapseKey = ""; + + /** + * @generated from field: bool always_deliver = 6; + */ + alwaysDeliver = false; + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 7; + */ + timestamp?: Timestamp; + + /** + * @generated from field: google.protobuf.Struct additional = 8; + */ + additional?: Struct; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "courier.Notification"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "recipient_did", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "collapse_key", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "always_deliver", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 7, name: "timestamp", kind: "message", T: Timestamp }, + { no: 8, name: "additional", kind: "message", T: Struct }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): Notification { + return new Notification().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): Notification { + return new Notification().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): Notification { + return new Notification().fromJsonString(jsonString, options); + } + + static equals(a: Notification | PlainMessage | undefined, b: Notification | PlainMessage | undefined): boolean { + return proto3.util.equals(Notification, a, b); + } +} + +/** + * @generated from message courier.PushNotificationsRequest + */ +export class PushNotificationsRequest extends Message { + /** + * @generated from field: repeated courier.Notification notifications = 1; + */ + notifications: Notification[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "courier.PushNotificationsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "notifications", kind: "message", T: Notification, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): PushNotificationsRequest { + return new PushNotificationsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): PushNotificationsRequest { + return new PushNotificationsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): PushNotificationsRequest { + return new PushNotificationsRequest().fromJsonString(jsonString, options); + } + + static equals(a: PushNotificationsRequest | PlainMessage | undefined, b: PushNotificationsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(PushNotificationsRequest, a, b); + } +} + +/** + * @generated from message courier.PushNotificationsResponse + */ +export class PushNotificationsResponse extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "courier.PushNotificationsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): PushNotificationsResponse { + return new PushNotificationsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): PushNotificationsResponse { + return new PushNotificationsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): PushNotificationsResponse { + return new PushNotificationsResponse().fromJsonString(jsonString, options); + } + + static equals(a: PushNotificationsResponse | PlainMessage | undefined, b: PushNotificationsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(PushNotificationsResponse, a, b); + } +} + +/** + * @generated from message courier.RegisterDeviceTokenRequest + */ +export class RegisterDeviceTokenRequest extends Message { + /** + * @generated from field: string did = 1; + */ + did = ""; + + /** + * @generated from field: string token = 2; + */ + token = ""; + + /** + * @generated from field: string app_id = 3; + */ + appId = ""; + + /** + * @generated from field: courier.AppPlatform platform = 4; + */ + platform = AppPlatform.UNSPECIFIED; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "courier.RegisterDeviceTokenRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "did", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "app_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "platform", kind: "enum", T: proto3.getEnumType(AppPlatform) }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RegisterDeviceTokenRequest { + return new RegisterDeviceTokenRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RegisterDeviceTokenRequest { + return new RegisterDeviceTokenRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RegisterDeviceTokenRequest { + return new RegisterDeviceTokenRequest().fromJsonString(jsonString, options); + } + + static equals(a: RegisterDeviceTokenRequest | PlainMessage | undefined, b: RegisterDeviceTokenRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(RegisterDeviceTokenRequest, a, b); + } +} + +/** + * @generated from message courier.RegisterDeviceTokenResponse + */ +export class RegisterDeviceTokenResponse extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "courier.RegisterDeviceTokenResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RegisterDeviceTokenResponse { + return new RegisterDeviceTokenResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RegisterDeviceTokenResponse { + return new RegisterDeviceTokenResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RegisterDeviceTokenResponse { + return new RegisterDeviceTokenResponse().fromJsonString(jsonString, options); + } + + static equals(a: RegisterDeviceTokenResponse | PlainMessage | undefined, b: RegisterDeviceTokenResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(RegisterDeviceTokenResponse, a, b); + } +} + diff --git a/packages/pds/src/signup-queue/notifier.ts b/packages/pds/src/signup-queue/notifier.ts index cd153a8e91e..19ae94e2e98 100644 --- a/packages/pds/src/signup-queue/notifier.ts +++ b/packages/pds/src/signup-queue/notifier.ts @@ -1,11 +1,30 @@ +import { Timestamp } from '@bufbuild/protobuf' +import { CourierClient } from '../courier' import { ServerMailer } from '../mailer' export class ActivationNotifier { - constructor(public mailer: ServerMailer) {} + constructor( + public mailer: ServerMailer, + public courierClient: CourierClient, + ) {} async sendEmail(email: string, handle: string) { await this.mailer.sendAccountActivated({ handle }, { to: email }) } - async sendPushNotif(email: string) {} + async sendPushNotif(did: string) { + await this.courierClient.pushNotifications({ + notifications: [ + { + id: `${did}-account-activated`, + recipientDid: did, + title: 'Great news!', + message: 'Your Bluesky account is ready to go', + collapseKey: 'account-activated', + alwaysDeliver: true, + timestamp: Timestamp.fromDate(new Date()), + }, + ], + }) + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbd04f5c644..c6680c575a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -498,6 +498,15 @@ importers: '@atproto/xrpc-server': specifier: workspace:^ version: link:../xrpc-server + '@bufbuild/protobuf': + specifier: ^1.5.0 + version: 1.7.1 + '@connectrpc/connect': + specifier: ^1.1.4 + version: 1.3.0(@bufbuild/protobuf@1.7.1) + '@connectrpc/connect-node': + specifier: ^1.1.4 + version: 1.3.0(@bufbuild/protobuf@1.7.1)(@connectrpc/connect@1.3.0) '@did-plc/lib': specifier: ^0.0.1 version: 0.0.1 @@ -595,6 +604,15 @@ importers: '@atproto/lex-cli': specifier: workspace:^ version: link:../lex-cli + '@bufbuild/buf': + specifier: ^1.28.1 + version: 1.29.0 + '@bufbuild/protoc-gen-es': + specifier: ^1.5.0 + version: 1.7.1(@bufbuild/protobuf@1.7.1) + '@connectrpc/protoc-gen-connect-es': + specifier: ^1.1.4 + version: 1.3.0(@bufbuild/protoc-gen-es@1.7.1)(@connectrpc/connect@1.3.0) '@did-plc/server': specifier: ^0.0.1 version: 0.0.1 @@ -4266,6 +4284,103 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true + /@bufbuild/buf-darwin-arm64@1.29.0: + resolution: {integrity: sha512-5hKxsARoY2WpWq1n5ONFqqGuauHb4yILKXCy37KRYCKiRLWmIP5yI3gWvWHKoH7sUJWTQmBqdJoCvYQr6ahQnw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@bufbuild/buf-darwin-x64@1.29.0: + resolution: {integrity: sha512-wOAPxbPLBns4AHiComWtdO1sx1J1p6mDYTbqmloHuI+B5U2rDbMsoHoe4nBcoMF8+RHxoqjypha29wVo6yzbZg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@bufbuild/buf-linux-aarch64@1.29.0: + resolution: {integrity: sha512-jLk2J/wyyM7KNJ/DkLfhy3eS2/Bdb70e/56adMkapSoLJmghnpgxW+oFznMxxQUX5I9BU5hTn1UhDFxgLwhP7g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@bufbuild/buf-linux-x64@1.29.0: + resolution: {integrity: sha512-heLOywj3Oaoh69RnTx7tHsuz6rEnvz77bghLEOghsrjBR6Jcpcwc137EZR4kRTIWJNrE8Kmo3RVeXlv144qQIQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@bufbuild/buf-win32-arm64@1.29.0: + resolution: {integrity: sha512-Eglyvr3PLqVucuHBcQ61conyBgH9BRaoLpKWcce1gYBVlxMQM1NxjVjGOWihxQ1dXXw5qZXmYfVODf3gSwPMuQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@bufbuild/buf-win32-x64@1.29.0: + resolution: {integrity: sha512-wRk6co+nqHqEq4iLolXgej0jUVlWlTtGHjKaq54lTbKZrwxrBgql6qS06abgNPRASX0++XT9m3QRZ97qEIC/HQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@bufbuild/buf@1.29.0: + resolution: {integrity: sha512-euksXeFtvlvAV5j94LqXb69qQcJvFfo8vN1d3cx+IzhOKoipykuQQTq7mOWVo2R0kdk6yIMBLBofOYOsh0Df8g==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@bufbuild/buf-darwin-arm64': 1.29.0 + '@bufbuild/buf-darwin-x64': 1.29.0 + '@bufbuild/buf-linux-aarch64': 1.29.0 + '@bufbuild/buf-linux-x64': 1.29.0 + '@bufbuild/buf-win32-arm64': 1.29.0 + '@bufbuild/buf-win32-x64': 1.29.0 + dev: true + + /@bufbuild/protobuf@1.7.1: + resolution: {integrity: sha512-UlI3lKLFBjZQJ0cHf47YUH6DzZxZYWk3sf6dKYyPUaXrfXq4z+zZqNO3q0lPUzyJgh14s6VscjcNFBaQBhYd9Q==} + + /@bufbuild/protoc-gen-es@1.7.1(@bufbuild/protobuf@1.7.1): + resolution: {integrity: sha512-N1diiVcDkTTNX+b9rDY8EVgOXu0W8kRmf2w3nbYi8q/hfM6vBg4zry0m4v3ARSgKp60bCey1WUDBuiynm5+PqQ==} + engines: {node: '>=14'} + hasBin: true + peerDependencies: + '@bufbuild/protobuf': 1.7.1 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + dependencies: + '@bufbuild/protobuf': 1.7.1 + '@bufbuild/protoplugin': 1.7.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@bufbuild/protoplugin@1.7.1: + resolution: {integrity: sha512-bnPFXs38IXjL2EdpkthkCa/+SXOxERnXyV///rQj1wyidJmw21wOvqpucuIh25YnPtdrUItcIFFDVCoKPkuCPQ==} + dependencies: + '@bufbuild/protobuf': 1.7.1 + '@typescript/vfs': 1.5.0 + typescript: 4.5.2 + transitivePeerDependencies: + - supports-color + dev: true + /@cbor-extract/cbor-extract-darwin-arm64@2.1.1: resolution: {integrity: sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==} cpu: [arm64] @@ -4517,6 +4632,46 @@ packages: prettier: 2.7.1 dev: true + /@connectrpc/connect-node@1.3.0(@bufbuild/protobuf@1.7.1)(@connectrpc/connect@1.3.0): + resolution: {integrity: sha512-2fV/z/8MUFOkTn2Gbm7T/qvRfkpt/D/w7ykYqACZRH6VNG/faY4lH2wUZiNkwv9tzTrECKOJFyPsaGs3nRYK3w==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@bufbuild/protobuf': ^1.4.2 + '@connectrpc/connect': 1.3.0 + dependencies: + '@bufbuild/protobuf': 1.7.1 + '@connectrpc/connect': 1.3.0(@bufbuild/protobuf@1.7.1) + undici: 5.28.2 + dev: false + + /@connectrpc/connect@1.3.0(@bufbuild/protobuf@1.7.1): + resolution: {integrity: sha512-kTeWxJnLLtxKc2ZSDN0rIBgwfP8RwcLknthX4AKlIAmN9ZC4gGnCbwp+3BKcP/WH5c8zGBAWqSY3zeqCM+ah7w==} + peerDependencies: + '@bufbuild/protobuf': ^1.4.2 + dependencies: + '@bufbuild/protobuf': 1.7.1 + + /@connectrpc/protoc-gen-connect-es@1.3.0(@bufbuild/protoc-gen-es@1.7.1)(@connectrpc/connect@1.3.0): + resolution: {integrity: sha512-UbQN48c0zafo5EFSsh3POIJP6ofYiAgKE1aFOZ2Er4W3flUYihydZdM6TQauPkn7jDj4w9jjLSTTZ9//ecUbPA==} + engines: {node: '>=16.0.0'} + hasBin: true + peerDependencies: + '@bufbuild/protoc-gen-es': ^1.6.0 + '@connectrpc/connect': 1.3.0 + peerDependenciesMeta: + '@bufbuild/protoc-gen-es': + optional: true + '@connectrpc/connect': + optional: true + dependencies: + '@bufbuild/protobuf': 1.7.1 + '@bufbuild/protoc-gen-es': 1.7.1(@bufbuild/protobuf@1.7.1) + '@bufbuild/protoplugin': 1.7.1 + '@connectrpc/connect': 1.3.0(@bufbuild/protobuf@1.7.1) + transitivePeerDependencies: + - supports-color + dev: true + /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -4623,6 +4778,11 @@ packages: - supports-color dev: true + /@fastify/busboy@2.1.0: + resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} + engines: {node: '>=14'} + dev: false + /@fastify/deepmerge@1.3.0: resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} @@ -5609,6 +5769,14 @@ packages: eslint-visitor-keys: 3.4.3 dev: true + /@typescript/vfs@1.5.0: + resolution: {integrity: sha512-AJS307bPgbsZZ9ggCT3wwpg3VbTKMFNHfaY/uF0ahSkYYrPF2dSSKDNIDIQAHm9qJqbLvCsSJH7yN4Vs/CsMMg==} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: true + /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: true @@ -11027,6 +11195,12 @@ packages: rxjs: 7.8.1 dev: false + /typescript@4.5.2: + resolution: {integrity: sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + /typescript@4.8.4: resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==} engines: {node: '>=4.2.0'} @@ -11061,6 +11235,13 @@ packages: which-boxed-primitive: 1.0.2 dev: true + /undici@5.28.2: + resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==} + engines: {node: '>=14.0'} + dependencies: + '@fastify/busboy': 2.1.0 + dev: false + /unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} From e908d8a7c93d425c38d48d96ad2eaee34cff14bf Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 15:41:55 -0600 Subject: [PATCH 085/135] cleanup, add cfg, add rate limiting on email --- packages/pds/package.json | 1 + packages/pds/src/config/config.ts | 20 ++++++ packages/pds/src/config/env.ts | 14 ++++ packages/pds/src/context.ts | 31 +++++++- packages/pds/src/signup-queue/activator.ts | 84 ++++++++++++++++++++-- packages/pds/src/signup-queue/notifier.ts | 30 -------- pnpm-lock.yaml | 3 + 7 files changed, 147 insertions(+), 36 deletions(-) delete mode 100644 packages/pds/src/signup-queue/notifier.ts diff --git a/packages/pds/package.json b/packages/pds/package.json index ce6e617607f..146efc228d5 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -70,6 +70,7 @@ "pg": "^8.10.0", "pino": "^8.15.0", "pino-http": "^8.2.1", + "rate-limiter-flexible": "^2.4.1", "sharp": "^0.32.6", "twilio": "^4.20.1", "typed-emitter": "^2.1.0", diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 6652a2a29eb..1c39f65a81b 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -210,6 +210,16 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { } : { enabled: false } + const courierHttpVersion = env.courierHttpVersion ?? '2' + assert(courierHttpVersion === '1.1' || courierHttpVersion === '2') + const activatorCfg: ServerConfig['activator'] = { + courierUrl: env.courierUrl, + courierHttpVersion, + courierIgnoreBadTls: env.courierIgnoreBadTls, + courierApiKey: env.courierApiKey, + emailsPerDay: env.activatorEmailsPerDay, + } + const crawlersCfg: ServerConfig['crawlers'] = env.crawlers ?? [] return { @@ -226,6 +236,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { modService: modServiceCfg, redis: redisCfg, rateLimits: rateLimitsCfg, + activator: activatorCfg, crawlers: crawlersCfg, } } @@ -244,6 +255,7 @@ export type ServerConfig = { modService: ModServiceConfig redis: RedisScratchConfig | null rateLimits: RateLimitsConfig + activator: ActivatorConfig crawlers: string[] } @@ -353,6 +365,14 @@ export type RateLimitsConfig = } | { enabled: false } +export type ActivatorConfig = { + courierUrl?: string + courierHttpVersion?: '1.1' | '2' + courierIgnoreBadTls?: boolean + courierApiKey?: string + emailsPerDay?: number +} + export type BksyAppViewConfig = { url: string did: string diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 1d332f9b8ff..b8d6cdc5b4b 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -86,6 +86,13 @@ export const readEnv = (): ServerEnvironment => { redisScratchAddress: envStr('PDS_REDIS_SCRATCH_ADDRESS'), redisScratchPassword: envStr('PDS_REDIS_SCRATCH_PASSWORD'), + // activator + courierUrl: envStr('PDS_COURIER_URL'), + courierHttpVersion: envStr('PDS_COURIER_HTTP_VERSION'), + courierIgnoreBadTls: envBool('PDS_COURIER_IGNORE_BAD_TLS'), + courierApiKey: envStr('PDS_COURIER_API_KEY'), + activatorEmailsPerDay: envInt('PDS_ACTIVATOR_EMAILS_PER_DAY'), + // crawlers crawlers: envList('PDS_CRAWLERS'), @@ -200,6 +207,13 @@ export type ServerEnvironment = { redisScratchAddress?: string redisScratchPassword?: string + // activator + courierUrl?: string + courierHttpVersion?: string + courierIgnoreBadTls?: boolean + courierApiKey?: string + activatorEmailsPerDay?: number + // crawler crawlers?: string[] diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 75224e3c83a..d5714de47f5 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -1,5 +1,6 @@ import * as nodemailer from 'nodemailer' import { Redis } from 'ioredis' +import { RateLimiterRedis } from 'rate-limiter-flexible' import * as plc from '@did-plc/lib' import * as crypto from '@atproto/crypto' import { IdResolver } from '@atproto/identity' @@ -25,6 +26,8 @@ import { TwilioClient } from './twilio' import assert from 'assert' import { SignupLimiter } from './signup-queue/limiter' import { SignupActivator } from './signup-queue/activator' +import { createCourierClient, authWithApiKey as courierAuth } from './courier' +import { DAY } from '@atproto/common' export type AppContextOptions = { db: Database @@ -231,7 +234,33 @@ export class AppContext { } const signupLimiter = new SignupLimiter(db) - const signupActivator = new SignupActivator(db) + const courierClient = cfg.activator.courierUrl + ? createCourierClient({ + baseUrl: cfg.activator.courierUrl, + httpVersion: cfg.activator.courierHttpVersion ?? '2', + nodeOptions: { + rejectUnauthorized: !cfg.activator.courierIgnoreBadTls, + }, + interceptors: cfg.activator.courierApiKey + ? [courierAuth(cfg.activator.courierApiKey)] + : [], + }) + : undefined + const limiter = + cfg.activator.emailsPerDay && redisScratch + ? new RateLimiterRedis({ + storeClient: redisScratch, + duration: DAY / 1000, + points: cfg.activator.emailsPerDay, + }) + : undefined + + const signupActivator = new SignupActivator({ + db, + mailer, + courierClient, + limiter, + }) const pdsAgents = new PdsAgents() diff --git a/packages/pds/src/signup-queue/activator.ts b/packages/pds/src/signup-queue/activator.ts index 99cd05c1e4e..94a98c57556 100644 --- a/packages/pds/src/signup-queue/activator.ts +++ b/packages/pds/src/signup-queue/activator.ts @@ -1,9 +1,13 @@ -import { SECOND, jitter, wait } from '@atproto/common' +import { RateLimiterAbstract } from 'rate-limiter-flexible' +import { SECOND, chunkArray, jitter, wait } from '@atproto/common' +import { DisconnectError } from '@atproto/xrpc-server' +import { Timestamp } from '@bufbuild/protobuf' import { limiterLogger as log } from '../logger' import Database from '../db' import { Leader } from '../db/leader' -import { DisconnectError } from '@atproto/xrpc-server' import { getQueueStatus } from './util' +import { ServerMailer } from '../mailer' +import { CourierClient } from '../courier' type LimiterFlags = { disableSignups: boolean @@ -17,16 +21,32 @@ type LimiterStatus = LimiterFlags & { export const ACCOUNT_ACTIVATOR_ID = 1010 +export type ActivatorOpts = { + db: Database + mailer?: ServerMailer + courierClient?: CourierClient + limiter?: RateLimiterAbstract +} + export class SignupActivator { leader: Leader + db: Database + mailer?: ServerMailer + courierClient?: CourierClient + limiter?: RateLimiterAbstract + destroyed = false promise: Promise = Promise.resolve() timer: NodeJS.Timer | undefined status: LimiterStatus - constructor(private db: Database, lockId = ACCOUNT_ACTIVATOR_ID) { - this.leader = new Leader(lockId, this.db) + constructor(opts: ActivatorOpts, lockId = ACCOUNT_ACTIVATOR_ID) { + this.leader = new Leader(lockId, opts.db) + this.db = opts.db + this.mailer = opts.mailer + this.courierClient = opts.courierClient + this.limiter = opts.limiter } async run() { @@ -99,6 +119,60 @@ export class SignupActivator { .execute() log.info({ count: activated.length }, 'activated accounts') - // @TODO send mail/push notifs + + const dids = activated.map((row) => row.did) + await Promise.all([ + this.sendActivationEmails(dids), + this.sendActivationPushNotifs(dids), + ]) + } + + async sendActivationEmails(dids: string[]) { + if (dids.length < 1 || !this.mailer) return + const users = await this.db.db + .selectFrom('user_account') + .innerJoin('did_handle', 'did_handle.did', 'user_account.did') + .where('did_handle.did', 'in', dids) + .select(['user_account.email', 'did_handle.handle']) + .execute() + for (const chunk of chunkArray(users, 100)) { + try { + await this.limiter?.consume('server-mailer-limit', chunk.length) + } catch (err) { + log.error({ err }, 'user activation email rate limit exceeded') + } + try { + await Promise.all( + chunk.map(({ email, handle }) => + this.mailer?.sendAccountActivated({ handle }, { to: email }), + ), + ) + } catch (err) { + log.error({ err, dids: chunk }, 'error sending activation emails') + } + await wait(SECOND) + } + } + + async sendActivationPushNotifs(dids: string[]) { + if (dids.length < 1 || !this.courierClient) return + for (const chunk of chunkArray(dids, 100)) { + const notifications = chunk.map((did) => ({ + id: `${did}-account-activated`, + recipientDid: did, + title: 'Great news!', + message: 'Your Bluesky account is ready to go', + collapseKey: 'account-activated', + alwaysDeliver: true, + timestamp: Timestamp.fromDate(new Date()), + })) + try { + await this.courierClient.pushNotifications({ + notifications, + }) + } catch (err) { + log.error({ err, dids: chunk }, 'error sending activation push notifs') + } + } } } diff --git a/packages/pds/src/signup-queue/notifier.ts b/packages/pds/src/signup-queue/notifier.ts deleted file mode 100644 index 19ae94e2e98..00000000000 --- a/packages/pds/src/signup-queue/notifier.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Timestamp } from '@bufbuild/protobuf' -import { CourierClient } from '../courier' -import { ServerMailer } from '../mailer' - -export class ActivationNotifier { - constructor( - public mailer: ServerMailer, - public courierClient: CourierClient, - ) {} - - async sendEmail(email: string, handle: string) { - await this.mailer.sendAccountActivated({ handle }, { to: email }) - } - - async sendPushNotif(did: string) { - await this.courierClient.pushNotifications({ - notifications: [ - { - id: `${did}-account-activated`, - recipientDid: did, - title: 'Great news!', - message: 'Your Bluesky account is ready to go', - collapseKey: 'account-activated', - alwaysDeliver: true, - timestamp: Timestamp.fromDate(new Date()), - }, - ], - }) - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6680c575a1..4d81c9ac3ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -579,6 +579,9 @@ importers: pino-http: specifier: ^8.2.1 version: 8.2.1 + rate-limiter-flexible: + specifier: ^2.4.1 + version: 2.4.1 sharp: specifier: ^0.32.6 version: 0.32.6 From 085bc87954769f2d71f3f7f4fb813b5d820def5c Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 17:06:42 -0600 Subject: [PATCH 086/135] build branch --- .github/workflows/build-and-push-pds-aws.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 097f782d88e..959dbfd7e94 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - signup-queueing-take2 env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From 5f830cb585713e405c1aca92d24a50fe359cf49a Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 17:21:34 -0600 Subject: [PATCH 087/135] run activator --- packages/pds/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 0d7e4fbbc4b..818584bb03a 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -164,6 +164,7 @@ export class PDS { await this.ctx.db.startListeningToChannels() await this.ctx.runtimeFlags.start() await this.ctx.signupLimiter.start() + await this.ctx.signupActivator.run() const server = this.app.listen(this.ctx.cfg.service.port) this.server = server this.server.keepAliveTimeout = 90000 @@ -175,6 +176,7 @@ export class PDS { async destroy(): Promise { await this.ctx.runtimeFlags.destroy() await this.ctx.signupLimiter.destroy() + await this.ctx.signupActivator.run() await this.ctx.sequencerLeader?.destroy() await this.terminator?.terminate() await this.ctx.backgroundQueue.destroy() From 64ff83aa11af5de6acfb9e715dbcddd87a7c0e5e Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 17:22:09 -0600 Subject: [PATCH 088/135] dont run activator in dev-env --- packages/dev-env/src/pds.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index e9b7cd7c692..979f40e8048 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -57,6 +57,7 @@ export class TestPds { const secrets = pds.envToSecrets(env) const server = await pds.PDS.create(cfg, secrets) + await server.ctx.signupActivator.destroy() // Separate migration db on postgres in case migration changes some // connection state that we need in the tests, e.g. "alter database ... set ..." From e7bd3ff97d73ff0b7c1b850ee6389fb388e568bc Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 17:36:51 -0600 Subject: [PATCH 089/135] run activator async --- packages/pds/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 818584bb03a..b384c165e40 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -164,7 +164,7 @@ export class PDS { await this.ctx.db.startListeningToChannels() await this.ctx.runtimeFlags.start() await this.ctx.signupLimiter.start() - await this.ctx.signupActivator.run() + this.ctx.signupActivator.run() const server = this.app.listen(this.ctx.cfg.service.port) this.server = server this.server.keepAliveTimeout = 90000 From 258734b51125a6827fe763a4b38e67d3d57cf4f2 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 18:07:02 -0600 Subject: [PATCH 090/135] round estimated time --- packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts index cb58726b9a7..2a94c4fa558 100644 --- a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts +++ b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts @@ -32,9 +32,10 @@ export default function (server: Server, ctx: AppContext) { !limiterStatus.disableSignups && limiterStatus.accountsInPeriod > 0 ) { - estimatedTimeMs = + estimatedTimeMs = Math.ceil( (placeInQueue * limiterStatus.periodMs) / - limiterStatus.accountsInPeriod + limiterStatus.accountsInPeriod, + ) } return { From 6c0571d5029271400a822c4a0720d73a96a04dbc Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 18:48:02 -0600 Subject: [PATCH 091/135] logging --- packages/pds/src/signup-queue/activator.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/pds/src/signup-queue/activator.ts b/packages/pds/src/signup-queue/activator.ts index 99cd05c1e4e..71842243725 100644 --- a/packages/pds/src/signup-queue/activator.ts +++ b/packages/pds/src/signup-queue/activator.ts @@ -79,8 +79,10 @@ export class SignupActivator { async activateBatch() { const status = await getQueueStatus(this.db) - if (status.disableSignups) return const toAdmit = status.periodAllowance - status.accountsInPeriod + log.info({ ...status, toAdmit }, 'activating accounts') + + if (status.disableSignups) return if (toAdmit < 1) return const activatedAt = new Date().toISOString() From c1bbd68f57a6830eb44d5fc72490c5b9740d72e0 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 19:10:11 -0600 Subject: [PATCH 092/135] real time limiter --- .../api/com/atproto/server/createAccount.ts | 2 +- .../api/com/atproto/temp/checkSignupQueue.ts | 18 ++++++++---------- packages/pds/src/signup-queue/limiter.ts | 19 ++++++++++++------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 1b322f76304..3064e6dd541 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -28,7 +28,7 @@ export default function (server: Server, ctx: AppContext) { points: 100, }, handler: async ({ input, req }) => { - const hasAvailability = ctx.signupLimiter.hasAvailability() + const hasAvailability = await ctx.signupLimiter.hasAvailability() const { did, diff --git a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts index 2a94c4fa558..a71fe5d1dae 100644 --- a/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts +++ b/packages/pds/src/api/com/atproto/temp/checkSignupQueue.ts @@ -25,17 +25,15 @@ export default function (server: Server, ctx: AppContext) { placeInQueue = res?.count } - const limiterStatus = ctx.signupLimiter.status + const limiter = ctx.signupLimiter let estimatedTimeMs: number | undefined - if ( - placeInQueue && - !limiterStatus.disableSignups && - limiterStatus.accountsInPeriod > 0 - ) { - estimatedTimeMs = Math.ceil( - (placeInQueue * limiterStatus.periodMs) / - limiterStatus.accountsInPeriod, - ) + if (placeInQueue && !limiter.flags.disableSignups) { + const accountsInPeriod = await limiter.accountsInPeriod() + if (accountsInPeriod > 0) { + estimatedTimeMs = Math.ceil( + (placeInQueue * limiter.flags.periodMs) / accountsInPeriod, + ) + } } return { diff --git a/packages/pds/src/signup-queue/limiter.ts b/packages/pds/src/signup-queue/limiter.ts index 934d04a73da..d8a3945931e 100644 --- a/packages/pds/src/signup-queue/limiter.ts +++ b/packages/pds/src/signup-queue/limiter.ts @@ -1,19 +1,20 @@ import { SECOND } from '@atproto/common' import { limiterLogger as log } from '../logger' import Database from '../db' -import { LimiterStatus, getQueueStatus } from './util' +import { LimiterFlags, getAccountsInPeriod, getQueueStatus } from './util' export class SignupLimiter { destroyed = false promise: Promise = Promise.resolve() timer: NodeJS.Timer | undefined - status: LimiterStatus + flags: LimiterFlags constructor(private db: Database) {} - hasAvailability(): boolean { - if (this.status.disableSignups) return false - return this.status.accountsInPeriod < this.status.periodAllowance + async hasAvailability(): Promise { + if (this.flags.disableSignups) return false + const accountsInPeriod = await this.accountsInPeriod() + return accountsInPeriod < this.flags.periodAllowance } async start() { @@ -38,9 +39,13 @@ export class SignupLimiter { await this.promise } + async accountsInPeriod(): Promise { + return getAccountsInPeriod(this.db, this.flags.periodMs) + } + async refresh() { - this.status = await getQueueStatus(this.db) + this.flags = await getQueueStatus(this.db) - log.info({ ...this.status }, 'limiter refresh') + log.info({ ...this.flags }, 'limiter refresh') } } From eb4cfb9db562d26d507613e0153158a3f0576b20 Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 30 Jan 2024 20:14:11 -0600 Subject: [PATCH 093/135] format --- packages/pds/buf.gen.yaml | 2 +- packages/pds/src/proto/courier_connect.ts | 24 +- packages/pds/src/proto/courier_pb.ts | 438 ++++++++++++++-------- 3 files changed, 303 insertions(+), 161 deletions(-) diff --git a/packages/pds/buf.gen.yaml b/packages/pds/buf.gen.yaml index 29f9668b5b8..a81e4248719 100644 --- a/packages/pds/buf.gen.yaml +++ b/packages/pds/buf.gen.yaml @@ -9,4 +9,4 @@ plugins: opt: - target=ts - import_extension=.ts - out: src/proto \ No newline at end of file + out: src/proto diff --git a/packages/pds/src/proto/courier_connect.ts b/packages/pds/src/proto/courier_connect.ts index cdf50cca128..04d482e0788 100644 --- a/packages/pds/src/proto/courier_connect.ts +++ b/packages/pds/src/proto/courier_connect.ts @@ -3,20 +3,27 @@ /* eslint-disable */ // @ts-nocheck -import { PingRequest, PingResponse, PushNotificationsRequest, PushNotificationsResponse, RegisterDeviceTokenRequest, RegisterDeviceTokenResponse } from "./courier_pb.ts"; -import { MethodKind } from "@bufbuild/protobuf"; +import { + PingRequest, + PingResponse, + PushNotificationsRequest, + PushNotificationsResponse, + RegisterDeviceTokenRequest, + RegisterDeviceTokenResponse, +} from './courier_pb.ts' +import { MethodKind } from '@bufbuild/protobuf' /** * @generated from service courier.Service */ export const Service = { - typeName: "courier.Service", + typeName: 'courier.Service', methods: { /** * @generated from rpc courier.Service.Ping */ ping: { - name: "Ping", + name: 'Ping', I: PingRequest, O: PingResponse, kind: MethodKind.Unary, @@ -25,7 +32,7 @@ export const Service = { * @generated from rpc courier.Service.PushNotifications */ pushNotifications: { - name: "PushNotifications", + name: 'PushNotifications', I: PushNotificationsRequest, O: PushNotificationsResponse, kind: MethodKind.Unary, @@ -34,11 +41,10 @@ export const Service = { * @generated from rpc courier.Service.RegisterDeviceToken */ registerDeviceToken: { - name: "RegisterDeviceToken", + name: 'RegisterDeviceToken', I: RegisterDeviceTokenRequest, O: RegisterDeviceTokenResponse, kind: MethodKind.Unary, }, - } -} as const; - + }, +} as const diff --git a/packages/pds/src/proto/courier_pb.ts b/packages/pds/src/proto/courier_pb.ts index 3d413c0a3ff..2e88dd630bc 100644 --- a/packages/pds/src/proto/courier_pb.ts +++ b/packages/pds/src/proto/courier_pb.ts @@ -3,8 +3,15 @@ /* eslint-disable */ // @ts-nocheck -import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; -import { Message, proto3, Struct, Timestamp } from "@bufbuild/protobuf"; +import type { + BinaryReadOptions, + FieldList, + JsonReadOptions, + JsonValue, + PartialMessage, + PlainMessage, +} from '@bufbuild/protobuf' +import { Message, proto3, Struct, Timestamp } from '@bufbuild/protobuf' /** * @generated from enum courier.AppPlatform @@ -31,12 +38,12 @@ export enum AppPlatform { WEB = 3, } // Retrieve enum metadata with: proto3.getEnumType(AppPlatform) -proto3.util.setEnumType(AppPlatform, "courier.AppPlatform", [ - { no: 0, name: "APP_PLATFORM_UNSPECIFIED" }, - { no: 1, name: "APP_PLATFORM_IOS" }, - { no: 2, name: "APP_PLATFORM_ANDROID" }, - { no: 3, name: "APP_PLATFORM_WEB" }, -]); +proto3.util.setEnumType(AppPlatform, 'courier.AppPlatform', [ + { no: 0, name: 'APP_PLATFORM_UNSPECIFIED' }, + { no: 1, name: 'APP_PLATFORM_IOS' }, + { no: 2, name: 'APP_PLATFORM_ANDROID' }, + { no: 3, name: 'APP_PLATFORM_WEB' }, +]) /** * Ping @@ -45,29 +52,40 @@ proto3.util.setEnumType(AppPlatform, "courier.AppPlatform", [ */ export class PingRequest extends Message { constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); + super() + proto3.util.initPartial(data, this) } - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "courier.PingRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - ]); + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'courier.PingRequest' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) - static fromBinary(bytes: Uint8Array, options?: Partial): PingRequest { - return new PingRequest().fromBinary(bytes, options); + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): PingRequest { + return new PingRequest().fromBinary(bytes, options) } - static fromJson(jsonValue: JsonValue, options?: Partial): PingRequest { - return new PingRequest().fromJson(jsonValue, options); + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): PingRequest { + return new PingRequest().fromJson(jsonValue, options) } - static fromJsonString(jsonString: string, options?: Partial): PingRequest { - return new PingRequest().fromJsonString(jsonString, options); + static fromJsonString( + jsonString: string, + options?: Partial, + ): PingRequest { + return new PingRequest().fromJsonString(jsonString, options) } - static equals(a: PingRequest | PlainMessage | undefined, b: PingRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(PingRequest, a, b); + static equals( + a: PingRequest | PlainMessage | undefined, + b: PingRequest | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(PingRequest, a, b) } } @@ -76,29 +94,40 @@ export class PingRequest extends Message { */ export class PingResponse extends Message { constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); + super() + proto3.util.initPartial(data, this) } - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "courier.PingResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - ]); + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'courier.PingResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) - static fromBinary(bytes: Uint8Array, options?: Partial): PingResponse { - return new PingResponse().fromBinary(bytes, options); + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): PingResponse { + return new PingResponse().fromBinary(bytes, options) } - static fromJson(jsonValue: JsonValue, options?: Partial): PingResponse { - return new PingResponse().fromJson(jsonValue, options); + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): PingResponse { + return new PingResponse().fromJson(jsonValue, options) } - static fromJsonString(jsonString: string, options?: Partial): PingResponse { - return new PingResponse().fromJsonString(jsonString, options); + static fromJsonString( + jsonString: string, + options?: Partial, + ): PingResponse { + return new PingResponse().fromJsonString(jsonString, options) } - static equals(a: PingResponse | PlainMessage | undefined, b: PingResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(PingResponse, a, b); + static equals( + a: PingResponse | PlainMessage | undefined, + b: PingResponse | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(PingResponse, a, b) } } @@ -109,75 +138,102 @@ export class Notification extends Message { /** * @generated from field: string id = 1; */ - id = ""; + id = '' /** * @generated from field: string recipient_did = 2; */ - recipientDid = ""; + recipientDid = '' /** * @generated from field: string title = 3; */ - title = ""; + title = '' /** * @generated from field: string message = 4; */ - message = ""; + message = '' /** * @generated from field: string collapse_key = 5; */ - collapseKey = ""; + collapseKey = '' /** * @generated from field: bool always_deliver = 6; */ - alwaysDeliver = false; + alwaysDeliver = false /** * @generated from field: google.protobuf.Timestamp timestamp = 7; */ - timestamp?: Timestamp; + timestamp?: Timestamp /** * @generated from field: google.protobuf.Struct additional = 8; */ - additional?: Struct; + additional?: Struct constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); + super() + proto3.util.initPartial(data, this) } - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "courier.Notification"; + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'courier.Notification' static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "recipient_did", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 5, name: "collapse_key", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 6, name: "always_deliver", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, - { no: 7, name: "timestamp", kind: "message", T: Timestamp }, - { no: 8, name: "additional", kind: "message", T: Struct }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): Notification { - return new Notification().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): Notification { - return new Notification().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): Notification { - return new Notification().fromJsonString(jsonString, options); - } - - static equals(a: Notification | PlainMessage | undefined, b: Notification | PlainMessage | undefined): boolean { - return proto3.util.equals(Notification, a, b); + { no: 1, name: 'id', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 2, + name: 'recipient_did', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { no: 3, name: 'title', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 4, name: 'message', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 5, + name: 'collapse_key', + kind: 'scalar', + T: 9 /* ScalarType.STRING */, + }, + { + no: 6, + name: 'always_deliver', + kind: 'scalar', + T: 8 /* ScalarType.BOOL */, + }, + { no: 7, name: 'timestamp', kind: 'message', T: Timestamp }, + { no: 8, name: 'additional', kind: 'message', T: Struct }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): Notification { + return new Notification().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): Notification { + return new Notification().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): Notification { + return new Notification().fromJsonString(jsonString, options) + } + + static equals( + a: Notification | PlainMessage | undefined, + b: Notification | PlainMessage | undefined, + ): boolean { + return proto3.util.equals(Notification, a, b) } } @@ -188,33 +244,57 @@ export class PushNotificationsRequest extends Message /** * @generated from field: repeated courier.Notification notifications = 1; */ - notifications: Notification[] = []; + notifications: Notification[] = [] constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); + super() + proto3.util.initPartial(data, this) } - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "courier.PushNotificationsRequest"; + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'courier.PushNotificationsRequest' static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "notifications", kind: "message", T: Notification, repeated: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): PushNotificationsRequest { - return new PushNotificationsRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): PushNotificationsRequest { - return new PushNotificationsRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): PushNotificationsRequest { - return new PushNotificationsRequest().fromJsonString(jsonString, options); - } - - static equals(a: PushNotificationsRequest | PlainMessage | undefined, b: PushNotificationsRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(PushNotificationsRequest, a, b); + { + no: 1, + name: 'notifications', + kind: 'message', + T: Notification, + repeated: true, + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): PushNotificationsRequest { + return new PushNotificationsRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): PushNotificationsRequest { + return new PushNotificationsRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): PushNotificationsRequest { + return new PushNotificationsRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | PushNotificationsRequest + | PlainMessage + | undefined, + b: + | PushNotificationsRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(PushNotificationsRequest, a, b) } } @@ -223,29 +303,46 @@ export class PushNotificationsRequest extends Message */ export class PushNotificationsResponse extends Message { constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); + super() + proto3.util.initPartial(data, this) } - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "courier.PushNotificationsResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - ]); + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'courier.PushNotificationsResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) - static fromBinary(bytes: Uint8Array, options?: Partial): PushNotificationsResponse { - return new PushNotificationsResponse().fromBinary(bytes, options); + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): PushNotificationsResponse { + return new PushNotificationsResponse().fromBinary(bytes, options) } - static fromJson(jsonValue: JsonValue, options?: Partial): PushNotificationsResponse { - return new PushNotificationsResponse().fromJson(jsonValue, options); + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): PushNotificationsResponse { + return new PushNotificationsResponse().fromJson(jsonValue, options) } - static fromJsonString(jsonString: string, options?: Partial): PushNotificationsResponse { - return new PushNotificationsResponse().fromJsonString(jsonString, options); + static fromJsonString( + jsonString: string, + options?: Partial, + ): PushNotificationsResponse { + return new PushNotificationsResponse().fromJsonString(jsonString, options) } - static equals(a: PushNotificationsResponse | PlainMessage | undefined, b: PushNotificationsResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(PushNotificationsResponse, a, b); + static equals( + a: + | PushNotificationsResponse + | PlainMessage + | undefined, + b: + | PushNotificationsResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(PushNotificationsResponse, a, b) } } @@ -256,51 +353,74 @@ export class RegisterDeviceTokenRequest extends Message) { - super(); - proto3.util.initPartial(data, this); + super() + proto3.util.initPartial(data, this) } - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "courier.RegisterDeviceTokenRequest"; + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'courier.RegisterDeviceTokenRequest' static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "did", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "app_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "platform", kind: "enum", T: proto3.getEnumType(AppPlatform) }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): RegisterDeviceTokenRequest { - return new RegisterDeviceTokenRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): RegisterDeviceTokenRequest { - return new RegisterDeviceTokenRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): RegisterDeviceTokenRequest { - return new RegisterDeviceTokenRequest().fromJsonString(jsonString, options); - } - - static equals(a: RegisterDeviceTokenRequest | PlainMessage | undefined, b: RegisterDeviceTokenRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(RegisterDeviceTokenRequest, a, b); + { no: 1, name: 'did', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 2, name: 'token', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { no: 3, name: 'app_id', kind: 'scalar', T: 9 /* ScalarType.STRING */ }, + { + no: 4, + name: 'platform', + kind: 'enum', + T: proto3.getEnumType(AppPlatform), + }, + ]) + + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): RegisterDeviceTokenRequest { + return new RegisterDeviceTokenRequest().fromBinary(bytes, options) + } + + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): RegisterDeviceTokenRequest { + return new RegisterDeviceTokenRequest().fromJson(jsonValue, options) + } + + static fromJsonString( + jsonString: string, + options?: Partial, + ): RegisterDeviceTokenRequest { + return new RegisterDeviceTokenRequest().fromJsonString(jsonString, options) + } + + static equals( + a: + | RegisterDeviceTokenRequest + | PlainMessage + | undefined, + b: + | RegisterDeviceTokenRequest + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(RegisterDeviceTokenRequest, a, b) } } @@ -309,29 +429,45 @@ export class RegisterDeviceTokenRequest extends Message { constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); + super() + proto3.util.initPartial(data, this) } - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "courier.RegisterDeviceTokenResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - ]); + static readonly runtime: typeof proto3 = proto3 + static readonly typeName = 'courier.RegisterDeviceTokenResponse' + static readonly fields: FieldList = proto3.util.newFieldList(() => []) - static fromBinary(bytes: Uint8Array, options?: Partial): RegisterDeviceTokenResponse { - return new RegisterDeviceTokenResponse().fromBinary(bytes, options); + static fromBinary( + bytes: Uint8Array, + options?: Partial, + ): RegisterDeviceTokenResponse { + return new RegisterDeviceTokenResponse().fromBinary(bytes, options) } - static fromJson(jsonValue: JsonValue, options?: Partial): RegisterDeviceTokenResponse { - return new RegisterDeviceTokenResponse().fromJson(jsonValue, options); + static fromJson( + jsonValue: JsonValue, + options?: Partial, + ): RegisterDeviceTokenResponse { + return new RegisterDeviceTokenResponse().fromJson(jsonValue, options) } - static fromJsonString(jsonString: string, options?: Partial): RegisterDeviceTokenResponse { - return new RegisterDeviceTokenResponse().fromJsonString(jsonString, options); + static fromJsonString( + jsonString: string, + options?: Partial, + ): RegisterDeviceTokenResponse { + return new RegisterDeviceTokenResponse().fromJsonString(jsonString, options) } - static equals(a: RegisterDeviceTokenResponse | PlainMessage | undefined, b: RegisterDeviceTokenResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(RegisterDeviceTokenResponse, a, b); + static equals( + a: + | RegisterDeviceTokenResponse + | PlainMessage + | undefined, + b: + | RegisterDeviceTokenResponse + | PlainMessage + | undefined, + ): boolean { + return proto3.util.equals(RegisterDeviceTokenResponse, a, b) } } - From 5f8f09422ee3facf59c9d09f6ce9f85a6b914ff5 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Wed, 31 Jan 2024 19:56:01 -0600 Subject: [PATCH 094/135] Entryway: non-transactional createAccount (#2109) make create account non transactional --- .../api/com/atproto/server/createAccount.ts | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 1fda28e0266..77af9a180bb 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -20,6 +20,7 @@ import Database from '../../../../db' import { didDocForSession } from './util' import { getPdsEndpoint } from '../../../../pds-agents' import { isThisPds } from '../../../proxy' +import { dbLogger as log } from '../../../../logger' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createAccount({ @@ -96,19 +97,6 @@ export default function (server: Server, ctx: AppContext) { throw err } - // Generate a real did with PLC - if (plcOp && !entrywayAssignedPds) { - try { - await ctx.plcClient.sendOperation(did, plcOp) - } catch (err) { - req.log.error( - { didKey: ctx.plcRotationKey.did(), handle }, - 'failed to create did:plc', - ) - throw err - } - } - // insert invite code use if (ctx.cfg.invites.required && inviteCode) { await ensureCodeIsAvailable(dbTxn, inviteCode, true) @@ -132,6 +120,10 @@ export default function (server: Server, ctx: AppContext) { .execute() } + if (!entrywayAssignedPds) { + await repoTxn.createRepo(did, [], now) + } + const { access, refresh } = await ctx.services .auth(dbTxn) .createSession({ @@ -140,6 +132,15 @@ export default function (server: Server, ctx: AppContext) { appPasswordName: null, }) + return { + did, + pdsDid: entrywayAssignedPds?.did ?? null, + accessJwt: access, + refreshJwt: refresh, + } + }) + + try { if (entrywayAssignedPds) { const agent = ctx.pdsAgents.get(entrywayAssignedPds.host) await agent.com.atproto.server.createAccount({ @@ -149,17 +150,21 @@ export default function (server: Server, ctx: AppContext) { recoveryKey: input.body.recoveryKey, }) } else { - // Setup repo root - await repoTxn.createRepo(did, [], now) - } - - return { - did, - pdsDid: entrywayAssignedPds?.did ?? null, - accessJwt: access, - refreshJwt: refresh, + assert(plcOp) + try { + await ctx.plcClient.sendOperation(did, plcOp) + } catch (err) { + req.log.error( + { didKey: ctx.plcRotationKey.did(), handle }, + 'failed to create did:plc', + ) + throw err + } } - }) + } catch (err) { + await cleanupUncreatedAccount(ctx, did) + throw err + } const didDoc = await didDocForSession(ctx, result) @@ -496,3 +501,21 @@ const randomIndexByWeight = (weights) => { const rand = Math.random() * sum return cumulative.findIndex((item) => item >= rand) } + +const cleanupUncreatedAccount = async ( + ctx: AppContext, + did: string, + tries = 0, +) => { + if (tries > 3) return + try { + await Promise.all([ + ctx.services.account(ctx.db).deleteAccount(did), + ctx.services.record(ctx.db).deleteForActor(did), + ctx.services.repo(ctx.db).deleteRepo(did), + ]) + } catch (err) { + log.error({ err, did, tries }, 'failed to clean up partially created user') + return cleanupUncreatedAccount(ctx, did, tries + 1) + } +} From 86655ea70b0ce2ff763b60721cf55b99d5292b34 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Wed, 31 Jan 2024 19:56:13 -0600 Subject: [PATCH 095/135] Phone verification bypass number (#2119) * add bypass phone number * trim earlier --- .../api/com/atproto/server/createAccount.ts | 68 +++++++++++-------- packages/pds/src/config/config.ts | 2 + packages/pds/src/config/env.ts | 2 + 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 77af9a180bb..1f487c2b770 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -44,33 +44,11 @@ export default function (server: Server, ctx: AppContext) { const now = new Date().toISOString() const passwordScrypt = await scrypt.genSaltAndHash(password) - let verificationPhone: string | undefined = undefined - if (ctx.cfg.phoneVerification.required && ctx.twilio) { - if (!input.body.verificationPhone) { - throw new InvalidRequestError( - `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, - 'InvalidPhoneVerification', - ) - } else if (!input.body.verificationCode) { - throw new InvalidRequestError( - `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, - 'InvalidPhoneVerification', - ) - } - verificationPhone = ctx.twilio.normalizePhoneNumber( - input.body.verificationPhone, - ) - const verified = await ctx.twilio.verifyCode( - verificationPhone, - input.body.verificationCode.trim(), - ) - if (!verified) { - throw new InvalidRequestError( - 'Could not verify phone number. Please try again.', - 'InvalidPhoneVerification', - ) - } - } + const verificationPhone = await ensurePhoneVerification( + ctx, + input.body.verificationPhone, + input.body.verificationCode?.trim(), + ) const result = await ctx.db.transaction(async (dbTxn) => { const actorTxn = ctx.services.account(dbTxn) @@ -491,6 +469,42 @@ const ensureUnusedHandleAndEmail = async ( } } +const ensurePhoneVerification = async ( + ctx: AppContext, + phone?: string, + code?: string, +): Promise => { + if (!ctx.cfg.phoneVerification.required || !ctx.twilio) { + return + } + + if (!phone) { + throw new InvalidRequestError( + `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, + 'InvalidPhoneVerification', + ) + } + if (ctx.cfg.phoneVerification.bypassPhoneNumber === phone) { + return undefined + } + + if (!code) { + throw new InvalidRequestError( + `Text verification is now required on this server. Please make sure you're using the latest version of the Bluesky app.`, + 'InvalidPhoneVerification', + ) + } + const normalizedPhone = ctx.twilio.normalizePhoneNumber(phone) + const verified = await ctx.twilio.verifyCode(normalizedPhone, code) + if (!verified) { + throw new InvalidRequestError( + 'Could not verify phone number. Please try again.', + 'InvalidPhoneVerification', + ) + } + return normalizedPhone +} + const randomIndexByWeight = (weights) => { let sum = 0 const cumulative = weights.map((weight) => { diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 6652a2a29eb..a96462e05ab 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -137,6 +137,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { twilioAccountSid: env.twilioAccountSid, twilioServiceSid: env.twilioServiceSid, accountsPerPhoneNumber: env.accountsPerPhoneNumber ?? 3, + bypassPhoneNumber: env.bypassPhoneNumber, } } @@ -322,6 +323,7 @@ export type PhoneVerificationConfig = twilioAccountSid: string twilioServiceSid: string accountsPerPhoneNumber: number + bypassPhoneNumber?: string } | { required: false diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 1d332f9b8ff..324d74889db 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -52,6 +52,7 @@ export const readEnv = (): ServerEnvironment => { // phone verification phoneVerificationRequired: envBool('PDS_PHONE_VERIFICATION_REQUIRED'), accountsPerPhoneNumber: envInt('PDS_ACCOUNTS_PER_PHONE_NUMBER'), + bypassPhoneNumber: envStr('PDS_BYPASS_PHONE_NUMBER'), twilioAccountSid: envStr('PDS_TWILIO_ACCOUNT_SID'), twilioAuthToken: envStr('PDS_TWILIO_AUTH_TOKEN'), twilioServiceSid: envStr('PDS_TWILIO_SERVICE_SID'), @@ -166,6 +167,7 @@ export type ServerEnvironment = { // phone verification phoneVerificationRequired?: boolean accountsPerPhoneNumber?: number + bypassPhoneNumber?: string twilioAccountSid?: string twilioAuthToken?: string twilioServiceSid?: string From a2a867f082d65c0a2be6f64d4bc603d0892858a2 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 31 Jan 2024 19:57:34 -0600 Subject: [PATCH 096/135] build branch --- .github/workflows/build-and-push-pds-aws.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 097f782d88e..8febb8ce234 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + - multi-pds-auth env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From d3c6d6a5f9e51ee07aa80dd1ee204e47d0591900 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 31 Jan 2024 20:24:55 -0600 Subject: [PATCH 097/135] no assert on plcOp --- packages/pds/src/api/com/atproto/server/createAccount.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 1f487c2b770..c1817f5b7cb 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -127,8 +127,7 @@ export default function (server: Server, ctx: AppContext) { handle: input.body.handle, recoveryKey: input.body.recoveryKey, }) - } else { - assert(plcOp) + } else if (plcOp) { try { await ctx.plcClient.sendOperation(did, plcOp) } catch (err) { From 311f2b086a4075e0900d5bf1c807c46b6c87f168 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 31 Jan 2024 20:49:09 -0600 Subject: [PATCH 098/135] bypass requestPhoneVerification --- .../src/api/com/atproto/temp/requestPhoneVerification.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts index 4869d431514..09087a38917 100644 --- a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts +++ b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts @@ -20,6 +20,12 @@ export default function (server: Server, ctx: AppContext) { if (!ctx.twilio || !ctx.cfg.phoneVerification.required) { throw new InvalidRequestError('phone verification not enabled') } + if ( + ctx.cfg.phoneVerification.bypassPhoneNumber && + ctx.cfg.phoneVerification.bypassPhoneNumber === input.body.phoneNumber + ) { + return + } const accountsPerPhoneNumber = ctx.cfg.phoneVerification.accountsPerPhoneNumber const phoneNumber = ctx.twilio.normalizePhoneNumber( From 1f64e22b5ed521f4eb05aeba16386098ab55adc1 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 31 Jan 2024 21:00:49 -0600 Subject: [PATCH 099/135] fix test --- packages/pds/tests/entryway.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pds/tests/entryway.test.ts b/packages/pds/tests/entryway.test.ts index 40ef7b191d5..5643ec29fff 100644 --- a/packages/pds/tests/entryway.test.ts +++ b/packages/pds/tests/entryway.test.ts @@ -547,6 +547,7 @@ describe('entryway', () => { did: dan, pdsDid: pds.ctx.cfg.service.did, appPasswordName: null, + deactivated: false, }) const expiredAccessToken = await authService.createAccessToken({ did: dan, @@ -580,6 +581,7 @@ describe('entryway', () => { did: session.did, pdsDid: pds.ctx.cfg.service.did, // not eve's pds appPasswordName: null, + deactivated: false, }) const attempt = (agent: AtpAgent) => agent.api.app.bsky.actor.putPreferences( From 6f661971744c3a844103881daa5004804da01b8c Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 31 Jan 2024 21:13:23 -0600 Subject: [PATCH 100/135] test for phone verification --- .../src/api/com/atproto/temp/requestPhoneVerification.ts | 3 ++- packages/pds/tests/phone-verification.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts index 09087a38917..55d691d022a 100644 --- a/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts +++ b/packages/pds/src/api/com/atproto/temp/requestPhoneVerification.ts @@ -22,7 +22,8 @@ export default function (server: Server, ctx: AppContext) { } if ( ctx.cfg.phoneVerification.bypassPhoneNumber && - ctx.cfg.phoneVerification.bypassPhoneNumber === input.body.phoneNumber + ctx.cfg.phoneVerification.bypassPhoneNumber === + input.body.phoneNumber.trim() ) { return } diff --git a/packages/pds/tests/phone-verification.test.ts b/packages/pds/tests/phone-verification.test.ts index a203a07ccf9..a904bf7151c 100644 --- a/packages/pds/tests/phone-verification.test.ts +++ b/packages/pds/tests/phone-verification.test.ts @@ -20,6 +20,7 @@ describe('phone verification', () => { twilioAccountSid: 'ACXXXXXXX', twilioAuthToken: 'AUTH', twilioServiceSid: 'VAXXXXXXXX', + bypassPhoneNumber: '+10000000000', }, }) ctx = network.pds.ctx @@ -153,6 +154,11 @@ describe('phone verification', () => { ) }) + it('bypasses phone number verification', async () => { + await requestCode('+10000000000') + await createAccountWithCode('+10000000000') + }) + it('normalizes phone numbers', async () => { const code1 = await requestCode('+1 (444)444-4444') expect(verificationCodes['+14444444444']).toEqual(code1) From c18985a726330fae517d285bbb43de312309d97a Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 1 Feb 2024 19:16:31 -0600 Subject: [PATCH 101/135] build branch --- .github/workflows/build-and-push-pds-aws.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 959dbfd7e94..90775935b20 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - signup-queueing-take2 + - signup-queueing-notify env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From fd4aec0fb21507757db1933fdecb2a687bbc8acf Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 1 Feb 2024 19:17:14 -0600 Subject: [PATCH 102/135] build branch --- .github/workflows/build-and-push-pds-aws.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 90775935b20..08a8b5c23d2 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - signup-queueing-notify + - signup-queue-notify env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From 32084200e8f2539b51bb7f320977f2e748ba63ce Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Sat, 3 Feb 2024 15:55:46 -0600 Subject: [PATCH 103/135] Add rate limits to email routes (#2129) * add rate limits to email routes * did based only * tweak --- .../atproto/server/requestAccountDelete.ts | 13 +++++++ .../server/requestEmailConfirmation.ts | 13 +++++++ .../com/atproto/server/requestEmailUpdate.ts | 13 +++++++ .../atproto/server/requestPasswordReset.ts | 39 +++++++++++++------ 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts index ca895852f4f..8cd5d614127 100644 --- a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts +++ b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts @@ -1,9 +1,22 @@ import { InvalidRequestError } from '@atproto/xrpc-server' +import { DAY, HOUR } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.requestAccountDelete({ + rateLimit: [ + { + durationMs: DAY, + points: 15, + calcKey: ({ auth }) => auth.credentials.did, + }, + { + durationMs: HOUR, + points: 5, + calcKey: ({ auth }) => auth.credentials.did, + }, + ], auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ auth }) => { const did = auth.credentials.did diff --git a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts index 97b2e53cc7a..b4cb6f366c0 100644 --- a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts +++ b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts @@ -1,9 +1,22 @@ import { InvalidRequestError } from '@atproto/xrpc-server' +import { DAY, HOUR } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.requestEmailConfirmation({ + rateLimit: [ + { + durationMs: DAY, + points: 15, + calcKey: ({ auth }) => auth.credentials.did, + }, + { + durationMs: HOUR, + points: 5, + calcKey: ({ auth }) => auth.credentials.did, + }, + ], auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ auth }) => { const did = auth.credentials.did diff --git a/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts index 5402fa6b887..a604b96baf2 100644 --- a/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts +++ b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts @@ -1,9 +1,22 @@ import { InvalidRequestError } from '@atproto/xrpc-server' +import { DAY, HOUR } from '@atproto/common' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.requestEmailUpdate({ + rateLimit: [ + { + durationMs: DAY, + points: 15, + calcKey: ({ auth }) => auth.credentials.did, + }, + { + durationMs: HOUR, + points: 5, + calcKey: ({ auth }) => auth.credentials.did, + }, + ], auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ auth }) => { const did = auth.credentials.did diff --git a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts index 61b17ebb9a9..d5fb4333a58 100644 --- a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts +++ b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts @@ -1,20 +1,35 @@ +import { DAY, HOUR } from '@atproto/common' import AppContext from '../../../../context' import { Server } from '../../../../lexicon' export default function (server: Server, ctx: AppContext) { - server.com.atproto.server.requestPasswordReset(async ({ input }) => { - const email = input.body.email.toLowerCase() + server.com.atproto.server.requestPasswordReset({ + rateLimit: [ + { + durationMs: DAY, + points: 15, + calcKey: ({ input }) => input.body.email.toLowerCase(), + }, + { + durationMs: HOUR, + points: 5, + calcKey: ({ input }) => input.body.email.toLowerCase(), + }, + ], + handler: async ({ input }) => { + const email = input.body.email.toLowerCase() - const user = await ctx.services.account(ctx.db).getAccountByEmail(email) + const user = await ctx.services.account(ctx.db).getAccountByEmail(email) - if (user) { - const token = await ctx.services - .account(ctx.db) - .createEmailToken(user.did, 'reset_password') - await ctx.mailer.sendResetPassword( - { handle: user.handle, token }, - { to: user.email }, - ) - } + if (user) { + const token = await ctx.services + .account(ctx.db) + .createEmailToken(user.did, 'reset_password') + await ctx.mailer.sendResetPassword( + { handle: user.handle, token }, + { to: user.email }, + ) + } + }, }) } From 3058f87e38fc99608881da4dd98d9fe742ee69b0 Mon Sep 17 00:00:00 2001 From: dholms Date: Sat, 3 Feb 2024 16:10:45 -0600 Subject: [PATCH 104/135] tweak password reset rate limit --- .../pds/src/api/com/atproto/server/requestPasswordReset.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts index d5fb4333a58..22f25925172 100644 --- a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts +++ b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts @@ -8,12 +8,14 @@ export default function (server: Server, ctx: AppContext) { { durationMs: DAY, points: 15, - calcKey: ({ input }) => input.body.email.toLowerCase(), + calcKey: ({ input, req }) => + `${input.body.email.toLowerCase()}-${req.ip}`, }, { durationMs: HOUR, points: 5, - calcKey: ({ input }) => input.body.email.toLowerCase(), + calcKey: ({ input, req }) => + `${input.body.email.toLowerCase()}-${req.ip}`, }, ], handler: async ({ input }) => { From 4127d0f806a5f00b34646f4cf4324857342006c1 Mon Sep 17 00:00:00 2001 From: dholms Date: Sat, 3 Feb 2024 16:12:52 -0600 Subject: [PATCH 105/135] tweak again --- .../src/api/com/atproto/server/requestPasswordReset.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts index 22f25925172..fc994ad983b 100644 --- a/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts +++ b/packages/pds/src/api/com/atproto/server/requestPasswordReset.ts @@ -7,15 +7,11 @@ export default function (server: Server, ctx: AppContext) { rateLimit: [ { durationMs: DAY, - points: 15, - calcKey: ({ input, req }) => - `${input.body.email.toLowerCase()}-${req.ip}`, + points: 50, }, { durationMs: HOUR, - points: 5, - calcKey: ({ input, req }) => - `${input.body.email.toLowerCase()}-${req.ip}`, + points: 15, }, ], handler: async ({ input }) => { From 346d04dc7b2aaf5427b98fd5b4e3b927ae021c60 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Sat, 3 Feb 2024 16:38:58 -0600 Subject: [PATCH 106/135] Clear email tokens on email update - entryway (#2130) delete email tokens on email update --- packages/pds/src/api/com/atproto/server/updateEmail.ts | 5 ++--- packages/pds/src/services/account/index.ts | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/updateEmail.ts b/packages/pds/src/api/com/atproto/server/updateEmail.ts index e0f9d9bc078..b3e4564bc98 100644 --- a/packages/pds/src/api/com/atproto/server/updateEmail.ts +++ b/packages/pds/src/api/com/atproto/server/updateEmail.ts @@ -35,9 +35,8 @@ export default function (server: Server, ctx: AppContext) { await ctx.db.transaction(async (dbTxn) => { const accntSrvce = ctx.services.account(dbTxn) - if (token) { - await accntSrvce.deleteEmailToken(did, 'update_email') - } + await accntSrvce.deleteAllEmailTokens(did) + if (user.email !== email) { try { await accntSrvce.updateEmail(did, email) diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index f52ef9d9bb7..add3550e5e7 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -546,6 +546,13 @@ export class AccountService { .executeTakeFirst() } + async deleteAllEmailTokens(did: string) { + await this.db.db + .deleteFrom('email_token') + .where('did', '=', did) + .executeTakeFirst() + } + async assertValidToken( did: string, purpose: EmailTokenPurpose, From 57b1ad54fa99aa2b114fcddcbf365666e2a5a8c9 Mon Sep 17 00:00:00 2001 From: dholms Date: Sat, 3 Feb 2024 19:18:03 -0600 Subject: [PATCH 107/135] protect against potential race --- .../server/requestEmailConfirmation.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts index b4cb6f366c0..bc1ddf7e871 100644 --- a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts +++ b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts @@ -20,14 +20,17 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ auth }) => { const did = auth.credentials.did - const user = await ctx.services.account(ctx.db).getAccount(did) - if (!user) { - throw new InvalidRequestError('user not found') - } - const token = await ctx.services - .account(ctx.db) - .createEmailToken(did, 'confirm_email') - await ctx.mailer.sendConfirmEmail({ token }, { to: user.email }) + const { token, email } = await ctx.db.transaction(async (dbTxn) => { + const token = await ctx.services + .account(dbTxn) + .createEmailToken(did, 'confirm_email') + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found') + } + return { token, email: user.email } + }) + await ctx.mailer.sendConfirmEmail({ token }, { to: email }) }, }) } From 444fc090e67194756256e68d24c72250ed7d036b Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 5 Feb 2024 11:13:46 -0600 Subject: [PATCH 108/135] build branch --- .github/workflows/build-and-push-pds-aws.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 08a8b5c23d2..8febb8ce234 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - signup-queue-notify + - multi-pds-auth env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} From 014ed34144898c8ac7d689383c7c940dd1302f53 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 5 Feb 2024 11:29:09 -0800 Subject: [PATCH 109/135] Update the activated account email template --- .../mailer/templates/account-activated.hbs | 291 +++++------------- 1 file changed, 85 insertions(+), 206 deletions(-) diff --git a/packages/pds/src/mailer/templates/account-activated.hbs b/packages/pds/src/mailer/templates/account-activated.hbs index a037d6e5d01..58d459ae7b5 100644 --- a/packages/pds/src/mailer/templates/account-activated.hbs +++ b/packages/pds/src/mailer/templates/account-activated.hbs @@ -1,251 +1,143 @@ + - - - - - + + + + + - - + + + -