diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json index 4764c7c14ae..ee501c28251 100644 --- a/lexicons/app/bsky/actor/defs.json +++ b/lexicons/app/bsky/actor/defs.json @@ -176,7 +176,8 @@ }, "hideRepliesByUnfollowed": { "type": "boolean", - "description": "Hide replies in the feed if they are not by followed users." + "description": "Hide replies in the feed if they are not by followed users.", + "default": true }, "hideRepliesByLikeCount": { "type": "integer", diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index a5bac9a4eaa..09e7f4877c4 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,11 @@ # @atproto/api +## 0.10.5 + +### Patch Changes + +- [#2279](https://github.com/bluesky-social/atproto/pull/2279) [`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655) Thanks [@gaearon](https://github.com/gaearon)! - Change Following feed prefs to only show replies from people you follow by default + ## 0.10.4 ### Patch Changes diff --git a/packages/api/package.json b/packages/api/package.json index dbb18da1786..f2a1f1cd9b6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/api", - "version": "0.10.4", + "version": "0.10.5", "license": "MIT", "description": "Client library for atproto and Bluesky", "keywords": [ diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index ae504f90b8d..ba625d690c1 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -17,7 +17,7 @@ import { sanitizeMutedWordValue } from './util' const FEED_VIEW_PREF_DEFAULTS = { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 2c83e28c37c..9b15bdeae8e 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -5202,6 +5202,7 @@ export const schemaDict = { type: 'boolean', description: 'Hide replies in the feed if they are not by followed users.', + default: true, }, hideRepliesByLikeCount: { type: 'integer', diff --git a/packages/api/src/client/types/app/bsky/actor/defs.ts b/packages/api/src/client/types/app/bsky/actor/defs.ts index 630f2454056..45b3826d5cd 100644 --- a/packages/api/src/client/types/app/bsky/actor/defs.ts +++ b/packages/api/src/client/types/app/bsky/actor/defs.ts @@ -197,7 +197,7 @@ export interface FeedViewPref { /** Hide replies in the feed. */ hideReplies?: boolean /** Hide replies in the feed if they are not by followed users. */ - hideRepliesByUnfollowed?: boolean + hideRepliesByUnfollowed: boolean /** Hide replies in the feed if they do not have this number of likes. */ hideRepliesByLikeCount?: number /** Hide reposts in the feed. */ diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index 273308625a0..6f77be38e51 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -226,7 +226,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -252,7 +252,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -278,7 +278,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -306,7 +306,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -336,7 +336,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -368,7 +368,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -400,7 +400,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -432,7 +432,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -464,7 +464,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -496,7 +496,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -534,7 +534,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -566,7 +566,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -598,7 +598,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -630,7 +630,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -662,7 +662,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -694,14 +694,14 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -733,14 +733,14 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -772,14 +772,14 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -811,14 +811,14 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, }, other: { hideReplies: true, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -907,7 +907,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#feedViewPref', feed: 'home', hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -916,7 +916,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#feedViewPref', feed: 'home', hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -946,7 +946,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -977,7 +977,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -1008,7 +1008,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -1039,7 +1039,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -1070,7 +1070,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: true, - hideRepliesByUnfollowed: true, + hideRepliesByUnfollowed: false, hideRepliesByLikeCount: 10, hideReposts: true, hideQuotePosts: true, @@ -1089,7 +1089,7 @@ describe('agent', () => { await agent.setFeedViewPrefs('home', { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -1112,7 +1112,7 @@ describe('agent', () => { feedViewPrefs: { home: { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, @@ -1155,7 +1155,7 @@ describe('agent', () => { $type: 'app.bsky.actor.defs#feedViewPref', feed: 'home', hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, diff --git a/packages/bsky/CHANGELOG.md b/packages/bsky/CHANGELOG.md index 102c40050eb..4035ba15f02 100644 --- a/packages/bsky/CHANGELOG.md +++ b/packages/bsky/CHANGELOG.md @@ -1,5 +1,14 @@ # @atproto/bsky +## 0.0.37 + +### Patch Changes + +- [#2279](https://github.com/bluesky-social/atproto/pull/2279) [`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655) Thanks [@gaearon](https://github.com/gaearon)! - Change Following feed prefs to only show replies from people you follow by default + +- Updated dependencies [[`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655)]: + - @atproto/api@0.10.5 + ## 0.0.36 ### Patch Changes diff --git a/packages/bsky/package.json b/packages/bsky/package.json index dd081d3c209..ff1725ca980 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsky", - "version": "0.0.36", + "version": "0.0.37", "license": "MIT", "description": "Reference implementation of app.bsky App View (Bluesky API)", "keywords": [ diff --git a/packages/bsky/src/api/blob-resolver.ts b/packages/bsky/src/api/blob-resolver.ts index 1a2d1ee560d..facb70c4d6f 100644 --- a/packages/bsky/src/api/blob-resolver.ts +++ b/packages/bsky/src/api/blob-resolver.ts @@ -106,7 +106,9 @@ export async function resolveBlob(ctx: AppContext, did: string, cid: CID) { throw createError(404, 'Blob not found') } - const blobResult = await retryHttp(() => getBlob({ pds, did, cid: cidStr })) + const blobResult = await retryHttp(() => + getBlob(ctx, { pds, did, cid: cidStr }), + ) const imageStream: Readable = blobResult.data const verifyCid = new VerifyCidTransform(cid) @@ -119,12 +121,40 @@ export async function resolveBlob(ctx: AppContext, did: string, cid: CID) { } } -async function getBlob(opts: { pds: string; did: string; cid: string }) { +async function getBlob( + ctx: AppContext, + opts: { pds: string; did: string; cid: string }, +) { const { pds, did, cid } = opts return axios.get(`${pds}/xrpc/com.atproto.sync.getBlob`, { params: { did, cid }, decompress: true, responseType: 'stream', timeout: 5000, // 5sec of inactivity on the connection + headers: getRateLimitBypassHeaders(ctx, pds), }) } + +function getRateLimitBypassHeaders( + ctx: AppContext, + pds: string, +): { 'x-ratelimit-bypass'?: string } { + const { + blobRateLimitBypassKey: bypassKey, + blobRateLimitBypassHostname: bypassHostname, + } = ctx.cfg + if (!bypassKey || !bypassHostname) { + return {} + } + const url = new URL(pds) + if (bypassHostname.startsWith('.')) { + if (url.hostname.endsWith(bypassHostname)) { + return { 'x-ratelimit-bypass': bypassKey } + } + } else { + if (url.hostname === bypassHostname) { + return { 'x-ratelimit-bypass': bypassKey } + } + } + return {} +} diff --git a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts index e01be2d6383..cee84d53177 100644 --- a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts +++ b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts @@ -5,7 +5,7 @@ import { INVALID_HANDLE } from '@atproto/syntax' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getAccountInfos({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { dids } = params const actors = await ctx.hydrator.actor.getActors(dids, true) diff --git a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts index 2ca7bcdc2c9..3be4c1c0185 100644 --- a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts @@ -5,7 +5,7 @@ import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSub export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { did, uri, blob } = params diff --git a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts index bb78832aa93..8256efbe7e5 100644 --- a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -10,7 +10,7 @@ import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/rep export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth) if (!canPerformTakedown) { diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 7798efa99b2..2936b1cd28b 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -25,7 +25,7 @@ export enum RoleStatus { type NullOutput = { credentials: { - type: 'null' + type: 'none' iss: null } } @@ -45,9 +45,9 @@ type RoleOutput = { } } -type AdminServiceOutput = { +type ModServiceOutput = { credentials: { - type: 'admin_service' + type: 'mod_service' aud: string iss: string } @@ -55,18 +55,18 @@ type AdminServiceOutput = { export type AuthVerifierOpts = { ownDid: string - adminDid: string + modServiceDid: string adminPasses: string[] } export class AuthVerifier { public ownDid: string - public adminDid: string + public modServiceDid: string private adminPasses: Set constructor(public dataplane: DataPlaneClient, opts: AuthVerifierOpts) { this.ownDid = opts.ownDid - this.adminDid = opts.adminDid + this.modServiceDid = opts.modServiceDid this.adminPasses = new Set(opts.adminPasses) } @@ -83,13 +83,21 @@ export class AuthVerifier { if (!this.parseRoleCreds(ctx.req).admin) { throw new AuthRequiredError('bad credentials') } - return { credentials: { type: 'standard', iss, aud } } + return { + credentials: { type: 'standard', iss, aud }, + } } const { iss, aud } = await this.verifyServiceJwt(ctx, { aud: this.ownDid, iss: null, }) - return { credentials: { type: 'standard', iss, aud } } + return { + credentials: { + type: 'standard', + iss, + aud, + }, + } } standardOptional = async ( @@ -159,19 +167,19 @@ export class AuthVerifier { } } - adminService = async (reqCtx: ReqCtx): Promise => { + modService = async (reqCtx: ReqCtx): Promise => { const { iss, aud } = await this.verifyServiceJwt(reqCtx, { aud: this.ownDid, - iss: [this.adminDid], + iss: [this.modServiceDid, `${this.modServiceDid}#atproto_labeler`], }) - return { credentials: { type: 'admin_service', aud, iss } } + return { credentials: { type: 'mod_service', aud, iss } } } - roleOrAdminService = async ( + roleOrModService = async ( reqCtx: ReqCtx, - ): Promise => { + ): Promise => { if (isBearerToken(reqCtx.req)) { - return this.adminService(reqCtx) + return this.modService(reqCtx) } else { return this.role(reqCtx) } @@ -195,12 +203,15 @@ export class AuthVerifier { opts: { aud: string | null; iss: string[] | null }, ) { const getSigningKey = async ( - did: string, + iss: string, _forceRefresh: boolean, // @TODO consider propagating to dataplane ): Promise => { - if (opts.iss !== null && !opts.iss.includes(did)) { + if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } + const [did, serviceId] = iss.split('#') + const keyId = + serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto' let identity: GetIdentityByDidResponse try { identity = await this.dataplane.getIdentityByDid({ did }) @@ -211,7 +222,7 @@ export class AuthVerifier { throw err } const keys = unpackIdentityKeys(identity.keys) - const didKey = getKeyAsDidKey(keys, { id: 'atproto' }) + const didKey = getKeyAsDidKey(keys, { id: keyId }) if (!didKey) { throw new AuthRequiredError('missing or bad key') } @@ -226,26 +237,36 @@ export class AuthVerifier { return { iss: payload.iss, aud: payload.aud } } + isModService(iss: string): boolean { + return [ + this.modServiceDid, + `${this.modServiceDid}#atproto_labeler`, + ].includes(iss) + } + nullCreds(): NullOutput { return { credentials: { - type: 'null', + type: 'none', iss: null, }, } } parseCreds( - creds: StandardOutput | RoleOutput | AdminServiceOutput | NullOutput, + creds: StandardOutput | RoleOutput | ModServiceOutput | NullOutput, ) { const viewer = creds.credentials.type === 'standard' ? creds.credentials.iss : null const canViewTakedowns = (creds.credentials.type === 'role' && creds.credentials.admin) || - creds.credentials.type === 'admin_service' + creds.credentials.type === 'mod_service' || + (creds.credentials.type === 'standard' && + this.isModService(creds.credentials.iss)) const canPerformTakedown = (creds.credentials.type === 'role' && creds.credentials.admin) || - creds.credentials.type === 'admin_service' + creds.credentials.type === 'mod_service' + return { viewer, canViewTakedowns, diff --git a/packages/bsky/src/config.ts b/packages/bsky/src/config.ts index 6f9c96776f4..3518f7b42a9 100644 --- a/packages/bsky/src/config.ts +++ b/packages/bsky/src/config.ts @@ -21,6 +21,8 @@ export interface ServerConfigValues { courierIgnoreBadTls?: boolean searchUrl?: string cdnUrl?: string + blobRateLimitBypassKey?: string + blobRateLimitBypassHostname?: string // identity didPlcUrl: string handleResolveNameservers?: string[] @@ -76,6 +78,15 @@ export class ServerConfig { const courierIgnoreBadTls = process.env.BSKY_COURIER_IGNORE_BAD_TLS === 'true' assert(courierHttpVersion === '1.1' || courierHttpVersion === '2') + const blobRateLimitBypassKey = + process.env.BSKY_BLOB_RATE_LIMIT_BYPASS_KEY || undefined + // single domain would be e.g. "mypds.com", subdomains are supported with a leading dot e.g. ".mypds.com" + const blobRateLimitBypassHostname = + process.env.BSKY_BLOB_RATE_LIMIT_BYPASS_HOSTNAME || undefined + assert( + !blobRateLimitBypassKey || blobRateLimitBypassHostname, + 'must specify a hostname when using a blob rate limit bypass key', + ) const adminPasswords = envList( process.env.BSKY_ADMIN_PASSWORDS || process.env.BSKY_ADMIN_PASSWORD, ) @@ -106,6 +117,8 @@ export class ServerConfig { courierApiKey, courierHttpVersion, courierIgnoreBadTls, + blobRateLimitBypassKey, + blobRateLimitBypassHostname, adminPasswords, modServiceDid, ...stripUndefineds(overrides ?? {}), @@ -197,6 +210,14 @@ export class ServerConfig { return this.cfg.cdnUrl } + get blobRateLimitBypassKey() { + return this.cfg.blobRateLimitBypassKey + } + + get blobRateLimitBypassHostname() { + return this.cfg.blobRateLimitBypassHostname + } + get didPlcUrl() { return this.cfg.didPlcUrl } diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index a968d85b584..b11fb42a453 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -100,7 +100,7 @@ export class BskyAppView { const authVerifier = new AuthVerifier(dataplane, { ownDid: config.serverDid, - adminDid: config.modServiceDid, + modServiceDid: config.modServiceDid, adminPasses: config.adminPasswords, }) diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 2c83e28c37c..9b15bdeae8e 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -5202,6 +5202,7 @@ export const schemaDict = { type: 'boolean', description: 'Hide replies in the feed if they are not by followed users.', + default: true, }, hideRepliesByLikeCount: { type: 'integer', diff --git a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts index 6836fa7e516..e219c846821 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts @@ -197,7 +197,7 @@ export interface FeedViewPref { /** Hide replies in the feed. */ hideReplies?: boolean /** Hide replies in the feed if they are not by followed users. */ - hideRepliesByUnfollowed?: boolean + hideRepliesByUnfollowed: boolean /** Hide replies in the feed if they do not have this number of likes. */ hideRepliesByLikeCount?: number /** Hide reposts in the feed. */ diff --git a/packages/common-web/src/did-doc.ts b/packages/common-web/src/did-doc.ts index 541e10d0937..715c364b3ca 100644 --- a/packages/common-web/src/did-doc.ts +++ b/packages/common-web/src/did-doc.ts @@ -27,6 +27,13 @@ export const getHandle = (doc: DidDocument): string | undefined => { // @NOTE we parse to type/publicKeyMultibase to avoid the dependency on @atproto/crypto export const getSigningKey = ( doc: DidDocument, +): { type: string; publicKeyMultibase: string } | undefined => { + return getVerificationMaterial(doc, 'atproto') +} + +export const getVerificationMaterial = ( + doc: DidDocument, + keyId: string, ): { type: string; publicKeyMultibase: string } | undefined => { const did = getDid(doc) let keys = doc.verificationMethod @@ -36,7 +43,7 @@ export const getSigningKey = ( keys = [keys] } const found = keys.find( - (key) => key.id === '#atproto' || key.id === `${did}#atproto`, + (key) => key.id === `#${keyId}` || key.id === `${did}#${keyId}`, ) if (!found?.publicKeyMultibase) return undefined return { @@ -44,6 +51,7 @@ export const getSigningKey = ( publicKeyMultibase: found.publicKeyMultibase, } } + export const getSigningDidKey = (doc: DidDocument): string | undefined => { const parsed = getSigningKey(doc) if (!parsed) return diff --git a/packages/dev-env/CHANGELOG.md b/packages/dev-env/CHANGELOG.md index 15a6ad3171f..da4e468d5bd 100644 --- a/packages/dev-env/CHANGELOG.md +++ b/packages/dev-env/CHANGELOG.md @@ -1,5 +1,15 @@ # @atproto/dev-env +## 0.2.37 + +### Patch Changes + +- Updated dependencies [[`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655)]: + - @atproto/ozone@0.0.16 + - @atproto/bsky@0.0.37 + - @atproto/api@0.10.5 + - @atproto/pds@0.4.5 + ## 0.2.36 ### Patch Changes diff --git a/packages/dev-env/package.json b/packages/dev-env/package.json index 46899cdb763..bab1f5daded 100644 --- a/packages/dev-env/package.json +++ b/packages/dev-env/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/dev-env", - "version": "0.2.36", + "version": "0.2.37", "license": "MIT", "description": "Local development environment helper for atproto development", "keywords": [ diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index c90e2c181f5..06fbd780060 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -3,12 +3,11 @@ import * as uint8arrays from 'uint8arrays' import getPort from 'get-port' import { wait } from '@atproto/common-web' import { createServiceJwt } from '@atproto/xrpc-server' -import { Client as PlcClient } from '@did-plc/lib' import { TestServerParams } from './types' import { TestPlc } from './plc' import { TestPds } from './pds' import { TestBsky } from './bsky' -import { TestOzone } from './ozone' +import { TestOzone, createOzoneDid } from './ozone' import { mockNetworkUtilities } from './util' import { TestNetworkNoAppView } from './network-no-appview' import { Secp256k1Keypair } from '@atproto/crypto' @@ -43,13 +42,7 @@ export class TestNetwork extends TestNetworkNoAppView { const ozonePort = params.ozone?.port ?? (await getPort()) const ozoneKey = await Secp256k1Keypair.create({ exportable: true }) - const ozoneDid = await new PlcClient(plc.url).createDid({ - signingKey: ozoneKey.did(), - rotationKeys: [ozoneKey.did()], - handle: 'ozone.test', - pds: `http://pds.invalid`, - signer: ozoneKey, - }) + const ozoneDid = await createOzoneDid(plc.url, ozoneKey) const bsky = await TestBsky.create({ port: bskyPort, diff --git a/packages/dev-env/src/ozone.ts b/packages/dev-env/src/ozone.ts index 39cf26f5c81..d06e45eba13 100644 --- a/packages/dev-env/src/ozone.ts +++ b/packages/dev-env/src/ozone.ts @@ -2,8 +2,8 @@ import getPort from 'get-port' import * as ui8 from 'uint8arrays' import * as ozone from '@atproto/ozone' import { AtpAgent } from '@atproto/api' -import { Secp256k1Keypair } from '@atproto/crypto' -import { Client as PlcClient } from '@did-plc/lib' +import { Keypair, Secp256k1Keypair } from '@atproto/crypto' +import * as plc from '@did-plc/lib' import { OzoneConfig } from './types' import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const' @@ -21,18 +21,12 @@ export class TestOzone { const signingKeyHex = ui8.toString(await serviceKeypair.export(), 'hex') let serverDid = config.serverDid if (!serverDid) { - const plcClient = new PlcClient(config.plcUrl) - serverDid = await plcClient.createDid({ - signingKey: serviceKeypair.did(), - rotationKeys: [serviceKeypair.did()], - handle: 'ozone.test', - pds: `https://pds.invalid`, - signer: serviceKeypair, - }) + serverDid = await createOzoneDid(config.plcUrl, serviceKeypair) } const port = config.port || (await getPort()) const url = `http://localhost:${port}` + const env: ozone.OzoneEnvironment = { devMode: true, version: '0.0.0', @@ -45,6 +39,9 @@ export class TestOzone { adminPassword: ADMIN_PASSWORD, moderatorPassword: MOD_PASSWORD, triagePassword: TRIAGE_PASSWORD, + adminDids: [], + moderatorDids: [], + triageDids: [], } // Separate migration db in case migration changes some connection state that we need in the tests, e.g. "alter database ... set ..." @@ -113,3 +110,31 @@ export class TestOzone { await this.server.destroy() } } + +export const createOzoneDid = async ( + plcUrl: string, + keypair: Keypair, +): Promise => { + const plcClient = new plc.Client(plcUrl) + const plcOp = await plc.signOperation( + { + type: 'plc_operation', + alsoKnownAs: [], + rotationKeys: [keypair.did()], + verificationMethods: { + atproto_label: keypair.did(), + }, + services: { + atproto_labeler: { + type: 'AtprotoLabeler', + endpoint: 'https://ozone.public.url', + }, + }, + prev: null, + }, + keypair, + ) + const did = await plc.didForCreateOp(plcOp) + await plcClient.sendOperation(did, plcOp) + return did +} diff --git a/packages/ozone/CHANGELOG.md b/packages/ozone/CHANGELOG.md index 0953945b4b2..474b82c8986 100644 --- a/packages/ozone/CHANGELOG.md +++ b/packages/ozone/CHANGELOG.md @@ -1,5 +1,14 @@ # @atproto/ozone +## 0.0.16 + +### Patch Changes + +- [#2279](https://github.com/bluesky-social/atproto/pull/2279) [`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655) Thanks [@gaearon](https://github.com/gaearon)! - Change Following feed prefs to only show replies from people you follow by default + +- Updated dependencies [[`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655)]: + - @atproto/api@0.10.5 + ## 0.0.15 ### Patch Changes diff --git a/packages/ozone/package.json b/packages/ozone/package.json index 3840f4b49ed..da3abaec419 100644 --- a/packages/ozone/package.json +++ b/packages/ozone/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/ozone", - "version": "0.0.15", + "version": "0.0.16", "license": "MIT", "description": "Backend service for moderating the Bluesky network.", "keywords": [ diff --git a/packages/ozone/src/api/admin/createCommunicationTemplate.ts b/packages/ozone/src/api/admin/createCommunicationTemplate.ts index 94d0f4325d9..f05db2d71f2 100644 --- a/packages/ozone/src/api/admin/createCommunicationTemplate.ts +++ b/packages/ozone/src/api/admin/createCommunicationTemplate.ts @@ -4,13 +4,13 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.createCommunicationTemplate({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db const { createdBy, ...template } = input.body - if (!access.admin) { + if (!access.isAdmin) { throw new AuthRequiredError( 'Must be an admin to create a communication template', ) diff --git a/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts b/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts index cb72bb46216..b70028e710d 100644 --- a/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts +++ b/packages/ozone/src/api/admin/deleteCommunicationTemplate.ts @@ -4,13 +4,13 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.deleteCommunicationTemplate({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db const { id } = input.body - if (!access.admin) { + if (!access.isAdmin) { throw new AuthRequiredError( 'Must be an admin to delete a communication template', ) diff --git a/packages/ozone/src/api/admin/emitModerationEvent.ts b/packages/ozone/src/api/admin/emitModerationEvent.ts index 0b7339a01aa..15021deaf0d 100644 --- a/packages/ozone/src/api/admin/emitModerationEvent.ts +++ b/packages/ozone/src/api/admin/emitModerationEvent.ts @@ -14,7 +14,7 @@ import { retryHttp } from '../../util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.emitModerationEvent({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db @@ -32,7 +32,7 @@ export default function (server: Server, ctx: AppContext) { // if less than moderator access then can only take ack and escalation actions if (isTakedownEvent || isReverseTakedownEvent) { - if (!access.moderator) { + if (!access.isModerator) { throw new AuthRequiredError( 'Must be a full moderator to take this type of action', ) @@ -40,7 +40,7 @@ export default function (server: Server, ctx: AppContext) { // Non admins should not be able to take down feed generators if ( - !access.admin && + !access.isAdmin && subject.recordPath?.includes('app.bsky.feed.generator/') ) { throw new AuthRequiredError( @@ -49,7 +49,7 @@ export default function (server: Server, ctx: AppContext) { } } // if less than moderator access then can not apply labels - if (!access.moderator && isLabelEvent) { + if (!access.isModerator && isLabelEvent) { throw new AuthRequiredError('Must be a full moderator to label content') } diff --git a/packages/ozone/src/api/admin/getModerationEvent.ts b/packages/ozone/src/api/admin/getModerationEvent.ts index e02757c79a3..fc6b433789e 100644 --- a/packages/ozone/src/api/admin/getModerationEvent.ts +++ b/packages/ozone/src/api/admin/getModerationEvent.ts @@ -3,7 +3,7 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationEvent({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const { id } = params const db = ctx.db diff --git a/packages/ozone/src/api/admin/getRecord.ts b/packages/ozone/src/api/admin/getRecord.ts index fbe9a1229d5..061fc87a0d6 100644 --- a/packages/ozone/src/api/admin/getRecord.ts +++ b/packages/ozone/src/api/admin/getRecord.ts @@ -6,7 +6,7 @@ import { AtUri } from '@atproto/syntax' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRecord({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params, auth }) => { const db = ctx.db @@ -22,7 +22,7 @@ export default function (server: Server, ctx: AppContext) { record.repo = addAccountInfoToRepoView( record.repo, accountInfo, - auth.credentials.moderator, + auth.credentials.isModerator, ) return { diff --git a/packages/ozone/src/api/admin/getRepo.ts b/packages/ozone/src/api/admin/getRepo.ts index 5da30f24524..bd0c03c13c6 100644 --- a/packages/ozone/src/api/admin/getRepo.ts +++ b/packages/ozone/src/api/admin/getRepo.ts @@ -5,7 +5,7 @@ import { addAccountInfoToRepoViewDetail, getPdsAccountInfo } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params, auth }) => { const { did } = params const db = ctx.db @@ -20,7 +20,7 @@ export default function (server: Server, ctx: AppContext) { const repo = addAccountInfoToRepoViewDetail( partialRepo, accountInfo, - auth.credentials.moderator, + auth.credentials.isModerator, ) return { encoding: 'application/json', diff --git a/packages/ozone/src/api/admin/listCommunicationTemplates.ts b/packages/ozone/src/api/admin/listCommunicationTemplates.ts index b2b4d191b39..d8a88947895 100644 --- a/packages/ozone/src/api/admin/listCommunicationTemplates.ts +++ b/packages/ozone/src/api/admin/listCommunicationTemplates.ts @@ -4,12 +4,12 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.listCommunicationTemplates({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ auth }) => { const access = auth.credentials const db = ctx.db - if (!access.moderator) { + if (!access.isModerator) { throw new AuthRequiredError( 'Must be a full moderator to view list of communication template', ) diff --git a/packages/ozone/src/api/admin/queryModerationEvents.ts b/packages/ozone/src/api/admin/queryModerationEvents.ts index 670cda96cbc..959ee2dcd37 100644 --- a/packages/ozone/src/api/admin/queryModerationEvents.ts +++ b/packages/ozone/src/api/admin/queryModerationEvents.ts @@ -4,7 +4,7 @@ import { getEventType } from '../moderation/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.queryModerationEvents({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const { subject, diff --git a/packages/ozone/src/api/admin/queryModerationStatuses.ts b/packages/ozone/src/api/admin/queryModerationStatuses.ts index fc491339ffa..2c4e0d0dd10 100644 --- a/packages/ozone/src/api/admin/queryModerationStatuses.ts +++ b/packages/ozone/src/api/admin/queryModerationStatuses.ts @@ -4,7 +4,7 @@ import { getReviewState } from '../moderation/util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.queryModerationStatuses({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const { subject, diff --git a/packages/ozone/src/api/admin/searchRepos.ts b/packages/ozone/src/api/admin/searchRepos.ts index fcdfc1b6d85..6026a5ccdc9 100644 --- a/packages/ozone/src/api/admin/searchRepos.ts +++ b/packages/ozone/src/api/admin/searchRepos.ts @@ -4,7 +4,7 @@ import { mapDefined } from '@atproto/common' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.searchRepos({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ params }) => { const modService = ctx.modService(ctx.db) diff --git a/packages/ozone/src/api/admin/updateCommunicationTemplate.ts b/packages/ozone/src/api/admin/updateCommunicationTemplate.ts index 3800748fc30..1b1b124e7f9 100644 --- a/packages/ozone/src/api/admin/updateCommunicationTemplate.ts +++ b/packages/ozone/src/api/admin/updateCommunicationTemplate.ts @@ -4,13 +4,13 @@ import AppContext from '../../context' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateCommunicationTemplate({ - auth: ctx.roleVerifier, + auth: ctx.authVerifier.modOrRole, handler: async ({ input, auth }) => { const access = auth.credentials const db = ctx.db const { id, updatedBy, ...template } = input.body - if (!access.admin) { + if (!access.isAdmin) { throw new AuthRequiredError( 'Must be an admin to update a communication template', ) diff --git a/packages/ozone/src/api/index.ts b/packages/ozone/src/api/index.ts index 54e52ffe292..c0bb0649f71 100644 --- a/packages/ozone/src/api/index.ts +++ b/packages/ozone/src/api/index.ts @@ -15,6 +15,7 @@ import createCommunicationTemplate from './admin/createCommunicationTemplate' import updateCommunicationTemplate from './admin/updateCommunicationTemplate' import deleteCommunicationTemplate from './admin/deleteCommunicationTemplate' import listCommunicationTemplates from './admin/listCommunicationTemplates' +import proxied from './proxied' export * as health from './health' @@ -36,5 +37,6 @@ export default function (server: Server, ctx: AppContext) { createCommunicationTemplate(server, ctx) updateCommunicationTemplate(server, ctx) deleteCommunicationTemplate(server, ctx) + proxied(server, ctx) return server } diff --git a/packages/ozone/src/api/moderation/createReport.ts b/packages/ozone/src/api/moderation/createReport.ts index e87b957e8d0..3abf5080e7d 100644 --- a/packages/ozone/src/api/moderation/createReport.ts +++ b/packages/ozone/src/api/moderation/createReport.ts @@ -9,10 +9,10 @@ import { ModerationLangService } from '../../mod-service/lang' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ // @TODO anonymous reports w/ optional auth are a temporary measure - auth: ctx.authOptionalAccessOrRoleVerifier, + auth: ctx.authVerifier.standardOptionalOrRole, handler: async ({ input, auth }) => { const requester = - 'did' in auth.credentials ? auth.credentials.did : ctx.cfg.service.did + 'iss' in auth.credentials ? auth.credentials.iss : ctx.cfg.service.did const { reasonType, reason } = input.body const subject = subjectFromInput(input.body.subject) diff --git a/packages/ozone/src/api/proxied.ts b/packages/ozone/src/api/proxied.ts new file mode 100644 index 00000000000..a2d5040989e --- /dev/null +++ b/packages/ozone/src/api/proxied.ts @@ -0,0 +1,116 @@ +import { Server } from '../lexicon' +import AppContext from '../context' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.actor.getProfile({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.actor.getProfile( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.actor.getProfiles({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.actor.getProfiles( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.feed.getAuthorFeed({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.feed.getAuthorFeed( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.feed.getPostThread({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.feed.getPostThread( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.feed.getFeedGenerator({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.feed.getFeedGenerator( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.graph.getFollows({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.graph.getFollows( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.graph.getFollowers({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.graph.getFollowers( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) + + server.app.bsky.graph.getList({ + auth: ctx.authVerifier.modOrRole, + handler: async (request) => { + const res = await ctx.appviewAgent.api.app.bsky.graph.getList( + request.params, + await ctx.appviewAuth(), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +} diff --git a/packages/ozone/src/api/temp/fetchLabels.ts b/packages/ozone/src/api/temp/fetchLabels.ts index fd0331487d1..b2fbfbb846a 100644 --- a/packages/ozone/src/api/temp/fetchLabels.ts +++ b/packages/ozone/src/api/temp/fetchLabels.ts @@ -8,13 +8,13 @@ import { export default function (server: Server, ctx: AppContext) { server.com.atproto.temp.fetchLabels({ - auth: ctx.authOptionalAccessOrRoleVerifier, + auth: ctx.authVerifier.standardOptionalOrRole, handler: async ({ auth, params }) => { const { limit } = params const since = params.since !== undefined ? new Date(params.since).toISOString() : '' const includeUnspeccedTakedowns = - auth.credentials.type === 'role' && auth.credentials.admin + auth.credentials.type === 'none' ? false : auth.credentials.isAdmin const labelRes = await ctx.db.db .selectFrom('label') .selectAll() diff --git a/packages/ozone/src/api/well-known.ts b/packages/ozone/src/api/well-known.ts index 9cbfa9efe53..17ee9ad3752 100644 --- a/packages/ozone/src/api/well-known.ts +++ b/packages/ozone/src/api/well-known.ts @@ -15,7 +15,7 @@ export const createRouter = (ctx: AppContext): express.Router => { id: ctx.cfg.service.did, verificationMethod: [ { - id: `${ctx.cfg.service.did}#atproto`, + id: `${ctx.cfg.service.did}#atproto_label`, type: 'Multikey', controller: ctx.cfg.service.did, publicKeyMultibase: ctx.signingKey.did().replace('did:key:', ''), @@ -23,8 +23,8 @@ export const createRouter = (ctx: AppContext): express.Router => { ], service: [ { - id: '#atproto_mod', - type: 'AtprotoModerationService', + id: '#atproto_labeler', + type: 'AtprotoLabeler', serviceEndpoint: `https://${hostname}`, }, ], diff --git a/packages/ozone/src/auth-verifier.ts b/packages/ozone/src/auth-verifier.ts new file mode 100644 index 00000000000..48ca241e6ef --- /dev/null +++ b/packages/ozone/src/auth-verifier.ts @@ -0,0 +1,218 @@ +import express from 'express' +import * as ui8 from 'uint8arrays' +import { IdResolver } from '@atproto/identity' +import { AuthRequiredError, verifyJwt } from '@atproto/xrpc-server' + +type ReqCtx = { + req: express.Request +} + +type RoleOutput = { + credentials: { + type: 'role' + isAdmin: boolean + isModerator: boolean + isTriage: true + } +} + +type ModeratorOutput = { + credentials: { + type: 'moderator' + aud: string + iss: string + isAdmin: boolean + isModerator: boolean + isTriage: true + } +} + +type StandardOutput = { + credentials: { + type: 'standard' + aud: string + iss: string + isAdmin: boolean + isModerator: boolean + isTriage: boolean + } +} + +type NullOutput = { + credentials: { + type: 'none' + iss: null + } +} + +export type AuthVerifierOpts = { + serviceDid: string + admins: string[] + moderators: string[] + triage: string[] + adminPassword: string + moderatorPassword: string + triagePassword: string +} + +export class AuthVerifier { + serviceDid: string + admins: string[] + moderators: string[] + triage: string[] + private adminPassword: string + private moderatorPassword: string + private triagePassword: string + + constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) { + this.serviceDid = opts.serviceDid + this.admins = opts.admins + this.moderators = opts.moderators + this.triage = opts.triage + this.adminPassword = opts.adminPassword + this.moderatorPassword = opts.moderatorPassword + this.triagePassword = opts.triagePassword + } + + modOrRole = async (reqCtx: ReqCtx): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.moderator(reqCtx) + } else { + return this.role(reqCtx) + } + } + + moderator = async (reqCtx: ReqCtx): Promise => { + const creds = await this.standard(reqCtx) + if (!creds.credentials.isTriage) { + throw new AuthRequiredError('not a moderator account') + } + return { + credentials: { + ...creds.credentials, + type: 'moderator', + isTriage: true, + }, + } + } + + standard = async (reqCtx: ReqCtx): Promise => { + const getSigningKey = async ( + did: string, + forceRefresh: boolean, + ): Promise => { + const atprotoData = await this.idResolver.did.resolveAtprotoData( + did, + forceRefresh, + ) + return atprotoData.signingKey + } + + const jwtStr = getJwtStrFromReq(reqCtx.req) + if (!jwtStr) { + throw new AuthRequiredError('missing jwt', 'MissingJwt') + } + const payload = await verifyJwt(jwtStr, this.serviceDid, getSigningKey) + const iss = payload.iss + const isAdmin = this.admins.includes(iss) + const isModerator = isAdmin || this.moderators.includes(iss) + const isTriage = isModerator || this.triage.includes(iss) + return { + credentials: { + type: 'standard', + iss, + aud: payload.aud, + isAdmin, + isModerator, + isTriage, + }, + } + } + + standardOptional = async ( + reqCtx: ReqCtx, + ): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.standard(reqCtx) + } + return this.nullCreds() + } + + standardOptionalOrRole = async ( + reqCtx: ReqCtx, + ): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.standard(reqCtx) + } else if (isBasicToken(reqCtx.req)) { + return this.role(reqCtx) + } else { + return this.nullCreds() + } + } + + role = async (reqCtx: ReqCtx): Promise => { + const parsed = parseBasicAuth(reqCtx.req.headers.authorization ?? '') + const { username, password } = parsed ?? {} + if (username !== 'admin') { + throw new AuthRequiredError() + } + const isAdmin = password === this.adminPassword + const isModerator = isAdmin || password === this.moderatorPassword + const isTriage = isModerator || password === this.triagePassword + if (!isTriage) { + throw new AuthRequiredError() + } + return { + credentials: { + type: 'role', + isAdmin, + isModerator, + isTriage, + }, + } + } + + nullCreds(): NullOutput { + return { + credentials: { + type: 'none', + iss: null, + }, + } + } +} + +const BEARER = 'Bearer ' +const BASIC = 'Basic ' + +const isBearerToken = (req: express.Request): boolean => { + return req.headers.authorization?.startsWith(BEARER) ?? false +} + +const isBasicToken = (req: express.Request): boolean => { + return req.headers.authorization?.startsWith(BASIC) ?? false +} + +export const getJwtStrFromReq = (req: express.Request): string | null => { + const { authorization } = req.headers + if (!authorization?.startsWith(BEARER)) { + return null + } + return authorization.slice(BEARER.length).trim() +} + +export const parseBasicAuth = ( + token: string, +): { username: string; password: string } | null => { + if (!token.startsWith(BASIC)) return null + const b64 = token.slice(BASIC.length) + let parsed: string[] + try { + parsed = ui8.toString(ui8.fromString(b64, 'base64pad'), 'utf8').split(':') + } catch (err) { + return null + } + const [username, password] = parsed + if (!username || !password) return null + return { username, password } +} diff --git a/packages/ozone/src/auth.ts b/packages/ozone/src/auth.ts deleted file mode 100644 index e996f068c49..00000000000 --- a/packages/ozone/src/auth.ts +++ /dev/null @@ -1,147 +0,0 @@ -import express from 'express' -import * as uint8arrays from 'uint8arrays' -import { AuthRequiredError, verifyJwt } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' -import { OzoneSecrets } from './config' - -const BASIC = 'Basic ' -const BEARER = 'Bearer ' - -export const authVerifier = ( - idResolver: IdResolver, - opts: { aud: string | null }, -) => { - const getSigningKey = async ( - did: string, - forceRefresh: boolean, - ): Promise => { - const atprotoData = await idResolver.did.resolveAtprotoData( - did, - forceRefresh, - ) - return atprotoData.signingKey - } - - return async (reqCtx: { req: express.Request; res: express.Response }) => { - const jwtStr = getJwtStrFromReq(reqCtx.req) - if (!jwtStr) { - throw new AuthRequiredError('missing jwt', 'MissingJwt') - } - const payload = await verifyJwt(jwtStr, opts.aud, getSigningKey) - return { credentials: { did: payload.iss }, artifacts: { aud: opts.aud } } - } -} - -export const authOptionalVerifier = ( - idResolver: IdResolver, - opts: { aud: string | null }, -) => { - const verifyAccess = authVerifier(idResolver, opts) - return async (reqCtx: { req: express.Request; res: express.Response }) => { - if (!reqCtx.req.headers.authorization) { - return { credentials: { did: null } } - } - return verifyAccess(reqCtx) - } -} - -export const authOptionalAccessOrRoleVerifier = ( - idResolver: IdResolver, - secrets: OzoneSecrets, - serverDid: string, -) => { - const verifyAccess = authVerifier(idResolver, { aud: serverDid }) - const verifyRole = roleVerifier(secrets) - return async (ctx: { req: express.Request; res: express.Response }) => { - const defaultUnAuthorizedCredentials = { - credentials: { did: null, type: 'unauthed' as const }, - } - if (!ctx.req.headers.authorization) { - return defaultUnAuthorizedCredentials - } - // For non-admin tokens, we don't want to consider alternative verifiers and let it fail if it fails - const isRoleAuthToken = ctx.req.headers.authorization?.startsWith(BASIC) - if (isRoleAuthToken) { - const result = await verifyRole(ctx) - return { - ...result, - credentials: { - type: 'role' as const, - ...result.credentials, - }, - } - } - const result = await verifyAccess(ctx) - return { - ...result, - credentials: { - type: 'access' as const, - ...result.credentials, - }, - } - } -} - -export const roleVerifier = - (secrets: OzoneSecrets) => - async (reqCtx: { req: express.Request; res: express.Response }) => { - const credentials = getRoleCredentials(secrets, reqCtx.req) - if (!credentials.valid) { - throw new AuthRequiredError() - } - return { credentials } - } - -export const getRoleCredentials = ( - secrets: OzoneSecrets, - req: express.Request, -) => { - const parsed = parseBasicAuth(req.headers.authorization || '') - const { username, password } = parsed ?? {} - if (username === 'admin' && password === secrets.triagePassword) { - return { valid: true, admin: false, moderator: false, triage: true } - } - if (username === 'admin' && password === secrets.moderatorPassword) { - return { valid: true, admin: false, moderator: true, triage: true } - } - if (username === 'admin' && password === secrets.adminPassword) { - return { valid: true, admin: true, moderator: true, triage: true } - } - return { valid: false, admin: false, moderator: false, triage: false } -} - -export const parseBasicAuth = ( - token: string, -): { username: string; password: string } | null => { - if (!token.startsWith(BASIC)) return null - const b64 = token.slice(BASIC.length) - let parsed: string[] - try { - parsed = uint8arrays - .toString(uint8arrays.fromString(b64, 'base64pad'), 'utf8') - .split(':') - } catch (err) { - return null - } - const [username, password] = parsed - if (!username || !password) return null - return { username, password } -} - -export const buildBasicAuth = (username: string, password: string): string => { - return ( - BASIC + - uint8arrays.toString( - uint8arrays.fromString(`${username}:${password}`, 'utf8'), - 'base64pad', - ) - ) -} - -export const getJwtStrFromReq = (req: express.Request): string | null => { - const { authorization } = req.headers - if (!authorization?.startsWith(BEARER)) { - return null - } - return authorization.slice(BEARER.length).trim() -} diff --git a/packages/ozone/src/config/config.ts b/packages/ozone/src/config/config.ts index 43e9dd23ae7..a74ad4aafc6 100644 --- a/packages/ozone/src/config/config.ts +++ b/packages/ozone/src/config/config.ts @@ -55,6 +55,11 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => { authToken: env.blobReportServiceAuthToken, } : undefined + const accessCfg: OzoneConfig['access'] = { + admins: env.adminDids, + moderators: env.moderatorDids, + triage: env.triageDids, + } return { service: serviceCfg, @@ -64,6 +69,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => { cdn: cdnCfg, identity: identityCfg, blobReportService: blobReportServiceCfg, + access: accessCfg, } } @@ -75,6 +81,7 @@ export type OzoneConfig = { cdn: CdnConfig identity: IdentityConfig blobReportService?: BlobReportServiceConfig + access: AccessConfig } export type ServiceConfig = { @@ -108,10 +115,16 @@ export type PdsConfig = { did: string } +export type CdnConfig = { + paths?: string[] +} + export type IdentityConfig = { plcUrl: string } -export type CdnConfig = { - paths?: string[] +export type AccessConfig = { + admins: string[] + moderators: string[] + triage: string[] } diff --git a/packages/ozone/src/config/env.ts b/packages/ozone/src/config/env.ts index 11638090148..009880f8b20 100644 --- a/packages/ozone/src/config/env.ts +++ b/packages/ozone/src/config/env.ts @@ -19,6 +19,9 @@ export const readEnv = (): OzoneEnvironment => { dbPoolIdleTimeoutMs: envInt('OZONE_DB_POOL_IDLE_TIMEOUT_MS'), didPlcUrl: envStr('OZONE_DID_PLC_URL'), cdnPaths: envList('OZONE_CDN_PATHS'), + adminDids: envList('OZONE_ADMIN_DIDS'), + moderatorDids: envList('OZONE_MODERATOR_DIDS'), + triageDids: envList('OZONE_TRIAGE_DIDS'), adminPassword: envStr('OZONE_ADMIN_PASSWORD'), moderatorPassword: envStr('OZONE_MODERATOR_PASSWORD'), triagePassword: envStr('OZONE_TRIAGE_PASSWORD'), @@ -46,6 +49,9 @@ export type OzoneEnvironment = { dbPoolIdleTimeoutMs?: number didPlcUrl?: string cdnPaths?: string[] + adminDids: string[] + moderatorDids: string[] + triageDids: string[] adminPassword?: string moderatorPassword?: string triagePassword?: string diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index f461be7f18b..8450d30c6cf 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -6,7 +6,6 @@ import { createServiceAuthHeaders } from '@atproto/xrpc-server' import { Database } from './db' import { OzoneConfig, OzoneSecrets } from './config' import { ModerationService, ModerationServiceCreator } from './mod-service' -import * as auth from './auth' import { BackgroundQueue } from './background' import assert from 'assert' import { EventPusher } from './daemon' @@ -16,6 +15,7 @@ import { CommunicationTemplateServiceCreator, } from './communication-service/template' import { BlobDiverter } from './daemon/blob-diverter' +import { AuthVerifier } from './auth-verifier' import { ImageInvalidator } from './image-invalidator' export type AppContextOptions = { @@ -30,6 +30,7 @@ export type AppContextOptions = { imgInvalidator?: ImageInvalidator backgroundQueue: BackgroundQueue sequencer: Sequencer + authVerifier: AuthVerifier } export class AppContext { @@ -59,7 +60,7 @@ export class AppContext { const createAuthHeaders = (aud: string) => createServiceAuthHeaders({ - iss: cfg.service.did, + iss: `${cfg.service.did}#atproto_labeler`, aud, keypair: signingKey, }) @@ -92,6 +93,16 @@ export class AppContext { const sequencer = new Sequencer(db) + const authVerifier = new AuthVerifier(idResolver, { + serviceDid: cfg.service.did, + admins: cfg.access.admins, + moderators: cfg.access.moderators, + triage: cfg.access.triage, + adminPassword: secrets.adminPassword, + moderatorPassword: secrets.moderatorPassword, + triagePassword: secrets.triagePassword, + }) + return new AppContext( { db, @@ -104,6 +115,7 @@ export class AppContext { idResolver, backgroundQueue, sequencer, + authVerifier, ...(overrides ?? {}), }, secrets, @@ -162,38 +174,12 @@ export class AppContext { return this.opts.sequencer } - get authVerifier() { - return auth.authVerifier(this.idResolver, { aud: this.cfg.service.did }) - } - - get authVerifierAnyAudience() { - return auth.authVerifier(this.idResolver, { aud: null }) - } - - get authOptionalVerifierAnyAudience() { - return auth.authOptionalVerifier(this.idResolver, { aud: null }) - } - - get authOptionalVerifier() { - return auth.authOptionalVerifier(this.idResolver, { - aud: this.cfg.service.did, - }) - } - - get authOptionalAccessOrRoleVerifier() { - return auth.authOptionalAccessOrRoleVerifier( - this.idResolver, - this.secrets, - this.cfg.service.did, - ) - } - - get roleVerifier() { - return auth.roleVerifier(this.secrets) + get authVerifier(): AuthVerifier { + return this.opts.authVerifier } async serviceAuthHeaders(aud: string) { - const iss = this.cfg.service.did + const iss = `${this.cfg.service.did}#atproto_labeler` return createServiceAuthHeaders({ iss, aud, diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index 2c83e28c37c..9b15bdeae8e 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -5202,6 +5202,7 @@ export const schemaDict = { type: 'boolean', description: 'Hide replies in the feed if they are not by followed users.', + default: true, }, hideRepliesByLikeCount: { type: 'integer', diff --git a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts index 6836fa7e516..e219c846821 100644 --- a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts @@ -197,7 +197,7 @@ export interface FeedViewPref { /** Hide replies in the feed. */ hideReplies?: boolean /** Hide replies in the feed if they are not by followed users. */ - hideRepliesByUnfollowed?: boolean + hideRepliesByUnfollowed: boolean /** Hide replies in the feed if they do not have this number of likes. */ hideRepliesByLikeCount?: number /** Hide reposts in the feed. */ diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 3161bc065ee..7e61491ab24 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -971,7 +971,7 @@ export class ModerationService { const isSafeUrl = (url: URL) => { if (url.protocol !== 'https:') return false if (!url.hostname || url.hostname === 'localhost') return false - if (net.isIP(url.hostname) === 0) return false + if (net.isIP(url.hostname) !== 0) return false return true } diff --git a/packages/ozone/src/mod-service/views.ts b/packages/ozone/src/mod-service/views.ts index 498091a8bd0..f65fd68b480 100644 --- a/packages/ozone/src/mod-service/views.ts +++ b/packages/ozone/src/mod-service/views.ts @@ -25,6 +25,7 @@ import { import { REASONOTHER } from '../lexicon/types/com/atproto/moderation/defs' import { subjectFromEventRow, subjectFromStatusRow } from './subject' import { formatLabel } from './util' +import { httpLogger as log } from '../logger' export type AuthHeaders = { headers: { @@ -43,15 +44,20 @@ export class ModerationViews { if (dids.length === 0) return new Map() const auth = await this.appviewAuth() if (!auth) return new Map() - const res = await this.appviewAgent.api.com.atproto.admin.getAccountInfos( - { - dids: dedupeStrs(dids), - }, - auth, - ) - return res.data.infos.reduce((acc, cur) => { - return acc.set(cur.did, cur) - }, new Map()) + try { + const res = await this.appviewAgent.api.com.atproto.admin.getAccountInfos( + { + dids: dedupeStrs(dids), + }, + auth, + ) + return res.data.infos.reduce((acc, cur) => { + return acc.set(cur.did, cur) + }, new Map()) + } catch (err) { + log.error({ err, dids }, 'failed to resolve account infos from appview') + return new Map() + } } async repos(dids: string[]): Promise> { diff --git a/packages/pds/CHANGELOG.md b/packages/pds/CHANGELOG.md index 1a3e87286f3..0589c99e10d 100644 --- a/packages/pds/CHANGELOG.md +++ b/packages/pds/CHANGELOG.md @@ -1,5 +1,14 @@ # @atproto/pds +## 0.4.5 + +### Patch Changes + +- [#2279](https://github.com/bluesky-social/atproto/pull/2279) [`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655) Thanks [@gaearon](https://github.com/gaearon)! - Change Following feed prefs to only show replies from people you follow by default + +- Updated dependencies [[`192223f12`](https://github.com/bluesky-social/atproto/commit/192223f127c0b226287df1ecfcd953636db08655)]: + - @atproto/api@0.10.5 + ## 0.4.4 ### Patch Changes diff --git a/packages/pds/package.json b/packages/pds/package.json index 062578aa531..3e3c9f753ec 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/pds", - "version": "0.4.4", + "version": "0.4.5", "license": "MIT", "description": "Reference implementation of atproto Personal Data Server (PDS)", "keywords": [ diff --git a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts index e258f9714b2..7269bc0dfa9 100644 --- a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts +++ b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts @@ -5,7 +5,7 @@ import { INVALID_HANDLE } from '@atproto/syntax' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getAccountInfo({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const [account, invites, invitedBy] = await Promise.all([ ctx.accountManager.getAccount(params.did, { diff --git a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts index 767714cec36..505e3dde4e9 100644 --- a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts @@ -7,7 +7,7 @@ import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSub export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { did, uri, blob } = params let body: OutputSchema | null = null diff --git a/packages/pds/src/api/com/atproto/admin/sendEmail.ts b/packages/pds/src/api/com/atproto/admin/sendEmail.ts index f6d8cce8d19..6e30159c204 100644 --- a/packages/pds/src/api/com/atproto/admin/sendEmail.ts +++ b/packages/pds/src/api/com/atproto/admin/sendEmail.ts @@ -6,7 +6,7 @@ import { resultPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.sendEmail({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { if (auth.credentials.type === 'role' && !auth.credentials.moderator) { throw new AuthRequiredError('Insufficient privileges') diff --git a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts index 29991da2b2c..018d447d6eb 100644 --- a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -11,7 +11,7 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.updateSubjectStatus({ - auth: ctx.authVerifier.roleOrAdminService, + auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { // if less than moderator access then cannot perform a takedown if (auth.credentials.type === 'role' && !auth.credentials.moderator) { diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 668791c187f..e12e0e8acfe 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -5,13 +5,14 @@ import { InvalidRequestError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' +import { IdResolver, getDidKeyFromMultibase } from '@atproto/identity' import * as ui8 from 'uint8arrays' import express from 'express' import * as jose from 'jose' import KeyEncoder from 'key-encoder' import { AccountManager } from './account-manager' import { softDeleted } from './db' +import { getVerificationMaterial } from '@atproto/common' type ReqCtx = { req: express.Request @@ -44,9 +45,9 @@ type RoleOutput = { } } -type AdminServiceOutput = { +type ModServiceOutput = { credentials: { - type: 'service' + type: 'mod_service' aud: string iss: string } @@ -97,7 +98,7 @@ export type AuthVerifierOpts = { dids: { pds: string entryway?: string - admin?: string + modService?: string } } @@ -253,28 +254,37 @@ export class AuthVerifier { } } - adminService = async (reqCtx: ReqCtx): Promise => { - if (!this.dids.admin) { + modService = async (reqCtx: ReqCtx): Promise => { + if (!this.dids.modService) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } const payload = await this.verifyServiceJwt(reqCtx, { - aud: this.dids.entryway ?? this.dids.pds, - iss: [this.dids.admin], + aud: null, + iss: [this.dids.modService, `${this.dids.modService}#atproto_labeler`], }) + if ( + payload.aud !== this.dids.pds && + (!this.dids.entryway || payload.aud !== this.dids.entryway) + ) { + throw new AuthRequiredError( + 'jwt audience does not match service did', + 'BadJwtAudience', + ) + } return { credentials: { - type: 'service', + type: 'mod_service', aud: payload.aud, iss: payload.iss, }, } } - roleOrAdminService = async ( + roleOrModService = async ( reqCtx: ReqCtx, - ): Promise => { + ): Promise => { if (isBearerToken(reqCtx.req)) { - return this.adminService(reqCtx) + return this.modService(reqCtx) } else { return this.role(reqCtx) } @@ -337,13 +347,28 @@ export class AuthVerifier { opts: { aud: string | null; iss: string[] | null }, ) { const getSigningKey = async ( - did: string, + iss: string, forceRefresh: boolean, ): Promise => { - if (opts.iss !== null && !opts.iss.includes(did)) { + if (opts.iss !== null && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } - return this.idResolver.did.resolveAtprotoKey(did, forceRefresh) + const [did, serviceId] = iss.split('#') + const keyId = + serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto' + const didDoc = await this.idResolver.did.resolve(did, forceRefresh) + if (!didDoc) { + throw new AuthRequiredError('could not resolve iss did') + } + const parsedKey = getVerificationMaterial(didDoc, keyId) + if (!parsedKey) { + throw new AuthRequiredError('missing or bad key in did doc') + } + const didKey = getDidKeyFromMultibase(parsedKey) + if (!didKey) { + throw new AuthRequiredError('missing or bad key in did doc') + } + return didKey } const jwtStr = bearerTokenFromReq(reqCtx.req) diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index a9fe996056f..4f852af1497 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -199,7 +199,7 @@ export class AppContext { dids: { pds: cfg.service.did, entryway: cfg.entryway?.did, - admin: cfg.modService?.did, + modService: cfg.modService?.did, }, }) diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 2c83e28c37c..9b15bdeae8e 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -5202,6 +5202,7 @@ export const schemaDict = { type: 'boolean', description: 'Hide replies in the feed if they are not by followed users.', + default: true, }, hideRepliesByLikeCount: { type: 'integer', diff --git a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts index 6836fa7e516..e219c846821 100644 --- a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts @@ -197,7 +197,7 @@ export interface FeedViewPref { /** Hide replies in the feed. */ hideReplies?: boolean /** Hide replies in the feed if they are not by followed users. */ - hideRepliesByUnfollowed?: boolean + hideRepliesByUnfollowed: boolean /** Hide replies in the feed if they do not have this number of likes. */ hideRepliesByLikeCount?: number /** Hide reposts in the feed. */ diff --git a/packages/pds/tests/admin-auth.test.ts b/packages/pds/tests/moderator-auth.test.ts similarity index 73% rename from packages/pds/tests/admin-auth.test.ts rename to packages/pds/tests/moderator-auth.test.ts index dffd9261874..0b89b4f0fd7 100644 --- a/packages/pds/tests/admin-auth.test.ts +++ b/packages/pds/tests/moderator-auth.test.ts @@ -1,43 +1,62 @@ import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' import AtpAgent from '@atproto/api' -import { Secp256k1Keypair } from '@atproto/crypto' +import { Keypair, Secp256k1Keypair } from '@atproto/crypto' import { createServiceAuthHeaders } from '@atproto/xrpc-server' +import * as plc from '@did-plc/lib' import usersSeed from './seeds/users' import { RepoRef } from '../src/lexicon/types/com/atproto/admin/defs' -describe('admin auth', () => { +describe('moderator auth', () => { let network: TestNetworkNoAppView let agent: AtpAgent let sc: SeedClient let repoSubject: RepoRef - const modServiceDid = 'did:example:mod' - const altModDid = 'did:example:alt' + let modServiceDid: string + let altModDid: string let modServiceKey: Secp256k1Keypair let pdsDid: string + const opAndDid = async (handle: string, key: Keypair) => { + const op = await plc.signOperation( + { + type: 'plc_operation', + alsoKnownAs: [handle], + verificationMethods: { + atproto: key.did(), + }, + rotationKeys: [key.did()], + services: {}, + prev: null, + }, + key, + ) + const did = await plc.didForCreateOp(op) + return { op, did } + } + beforeAll(async () => { + // kinda goofy but we need to know the dids before creating the testnet for the PDS's config + modServiceKey = await Secp256k1Keypair.create() + const modServiceInfo = await opAndDid('mod.test', modServiceKey) + const altModInfo = await opAndDid('alt-mod.test', modServiceKey) + modServiceDid = modServiceInfo.did + altModDid = altModInfo.did + network = await TestNetworkNoAppView.create({ - dbPostgresSchema: 'pds_admin_auth', + dbPostgresSchema: 'pds_moderator_auth', pds: { - modServiceDid, + modServiceDid: modServiceInfo.did, + modServiceUrl: 'https://mod.invalid', }, }) 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) - } + const plcClient = network.plc.getClient() + await plcClient.sendOperation(modServiceInfo.did, modServiceInfo.op) + await plcClient.sendOperation(altModInfo.did, altModInfo.op) agent = network.pds.getClient() sc = network.getSeedClient() diff --git a/packages/xrpc-server/src/auth.ts b/packages/xrpc-server/src/auth.ts index db6471aa23e..32373248f51 100644 --- a/packages/xrpc-server/src/auth.ts +++ b/packages/xrpc-server/src/auth.ts @@ -48,7 +48,7 @@ const jsonToB64Url = (json: Record): string => { export const verifyJwt = async ( jwtStr: string, ownDid: string | null, // null indicates to skip the audience check - getSigningKey: (did: string, forceRefresh: boolean) => Promise, + getSigningKey: (iss: string, forceRefresh: boolean) => Promise, ): Promise => { const parts = jwtStr.split('.') if (parts.length !== 3) {