diff --git a/.claude/skills/backend-lexification/SKILL.md b/.claude/skills/backend-lexification/SKILL.md new file mode 100644 index 00000000000..77f6746f41d --- /dev/null +++ b/.claude/skills/backend-lexification/SKILL.md @@ -0,0 +1,904 @@ +--- +name: backend-lexification +description: > + Migrate backend apis from `@atproto/api`, `@atproto/lexicon`, `@atproto/xrpc`, + and `@atproto/lex-cli` to `@atproto/lex`. Use this skill whenever the user + wants to adopt `@atproto/lex` in a package that currently relies on + `@atproto/lex-cli` to perform code generation, replace AtpAgent with Client, + migrate generated lexicon code to the new `lex build` output, replace + `ids.XxxYyy` with namespace accessors, adopt branded string types, switch from + `jsonStringToLex` to `lexParse`, or any refactoring that moves code from the + old generated lexicon system to `@atproto/lex`. Also trigger when the user + mentions "lexification", "lex SDK", "lex migration", or asks about replacing + `@atproto/api` imports in service code. +--- + +# Lexification: Migrating to `@atproto/lex` + +This skill describes how to refactor an AT Protocol service package to replace: + +- `@atproto/api` (the old high-level client) +- `@atproto/lexicon` (the old runtime lexicon library) +- `@atproto/xrpc` (the old XRPC client types) +- `@atproto/lex-cli` codegen (the old code generator that produced `src/lexicon/` directories) + +...with `@atproto/lex`, which provides generated TypeScript schemas with type-safe validation, type guards, builders, and an XRPC client. + +For the full `@atproto/lex` API reference, read `packages/lex/lex/README.md`. + +## Guiding Principles + +- **Minimize runtime changes.** Prefer type-level changes over runtime changes. If the old code works correctly at runtime, keep its logic and just improve the types around it. +- **Tests still use `@atproto/api` exclusively.** Do not migrate test files — they will be migrated in a separate phase. Tests that imported from the old `src/lexicon/` should switch to importing from `@atproto/api` instead. + +## What `@atproto/lex` Provides + +- **Generated TypeScript schemas** via `lex build` (replaces `lex-cli`'s `lex gen-server`) +- **Runtime utilities**: `lexParse`, `lexStringify`, `toDatetimeString`, `currentDatetimeString` +- **Branded string types**: `DidString`, `HandleString`, `AtIdentifierString`, `AtUriString`, `UriString`, `DatetimeString` +- **Type guards**: `isDidString`, `isHandleString`, `isAtIdentifierString`, `isAtUriString` +- **Data types**: `Cid`, `parseCid`, `BlobRef` (from `@atproto/lex-data` sub-export, also re-exported from `@atproto/lex`) +- **`Client` class**: replaces `AtpAgent` for service-to-service XRPC calls +- **`xrpc` / `xrpcSafe` functions**: standalone XRPC requests with structured error handling +- **`XrpcError`**: replaces `XRPCError` from `@atproto/xrpc` +- **Type-safe schema accessors**: `$type`, `$matches`, `$isTypeOf`, `$build`, `$safeParse`, `$lxm`, etc. + +The generated schemas live in `src/lexicons/` (note the plural). They expose a namespace-based API (e.g., `app.bsky.feed.post`) with `$type`, `$build`, `$check`, `$matches`, `$isTypeOf`, `$parse`, `$validate`, `$safeParse`, `$lxm`, and other utilities. + +## Overview of Changes + +The refactor touches these areas (in recommended order): + +1. **Project Configuration** - Update `package.json` dependencies, build scripts and git configuration +2. **Project Code Setup** - Replace server creation, endpoint registration, imports from old generated code, app context initialization (`AtpAgent` → `Client`), and type aliases with new patterns from `@atproto/lex` +3. **Endpoint Handlers Registration** - New `server.add()` pattern, handler return types, LXM references, and parameter/output/record type replacements +4. **XRPC Client Calls** - Replace `AtpAgent` API calls with `Client.xrpc()`, `Client.call()`, or `xrpcSafe()` +5. **Type Strictness** - Use branded types (`DidString`, `HandleString`, `AtUriString`, `Cid`, etc.) +6. **Data Utilities** - Replace old data manipulation functions (`jsonStringToLex` → `lexParse`, datetime helpers, `BlobRef`, etc.) +7. **Schema Validation and Type Guards** - Replace old `isX()` functions with `$matches()` / `$isTypeOf` / `$build()` + +## 1. Project Configuration + +### Dependencies + +Add `@atproto/lex` as a dependency. + +Remove from dependencies (if present): + +- `multiformats` (CID handling is now in `@atproto/lex-data`) + +Remove from devDependencies: + +- `@atproto/api` (unless still needed for tests in this phase) +- `@atproto/lexicon` +- `@atproto/lex-cli` +- `@atproto/xrpc` + +### Lexicon installation + +Remove the old `src/lexicon/` directory (singular) and any codegen scripts that referenced `lex gen-server`. We will also remove the (manually maintained) `./lexicons/` directory in order to manage installed lexicons with the new `lex install` commands: + +```sh +rm -rf ./src/lexicon +rm -rf ./lexicons +``` + +Install every lexicon NSID that the code uses. Run `lex install` once per NSID (or pass multiple NSIDs at once): + +```sh +lex install com.atproto.identity.resolveHandle app.bsky.feed.post +``` + +> [!NOTE] +> Some systems (like MacOS) already have a `lex` command. If that is the case, use `npx lex`, `pnpm exec lex` or `yarn lex` to run the correct binary. + +This creates a `manifest.json` and a local `./lexicons/` directory with the schema files the package depends on. + +The `manifest.json` file and `./lexicons/` directory (schema inputs) should be committed to git. + +### Code generation + +Replace the old codegen script with `lex build` as a prebuild step: + +```diff +- "codegen": "lex gen-server --yes ./src/lexicon ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ...", ++ "prebuild": "lex build --lexicons ./lexicons --clear --indexFile", ++ "postinstall": "lex install --ci", +``` + +The `--indexFile` flag generates an index file that re-exports all root-level namespaces, and `--clear` ensures a clean output directory on each build. + +The `lex install --ci` command will ensure that the `manifest.json` is up to date with the installed lexicons. Using the `postinstall` hook ensures that the command runs after `npm install` or `yarn install`, which ensures lexicon integrity in CI environments. + +The `./src/lexicons/` directory (generated output) should be gitignored since it is regenerated on every build: + +```sh +echo '/src/lexicons/' >> .gitignore +``` + +## 2. Project Code Setup + +After running `lex build`, the new generated code lives in `src/lexicons/` (plural). Import the namespace objects from the index file: + +```typescript +import { app, com, chat } from '../lexicons/index.js' +``` + +Each namespace provides access to schemas through dot notation: + +- `app.bsky.feed.post` - a record schema +- `app.bsky.feed.defs.postView` - an object definition +- `com.atproto.admin.defs.repoRef` - another object definition +- `app.bsky.feed.getAuthorFeed` - a query/procedure schema + +The old codegen used `ids` for NSID string constants (e.g., `ids.AppBskyFeedPost`). These are replaced with `$type` or `$lxm` properties on the schema objects. + +> [!NOTE] +> +> If the app's build process & bundler supports it, consider using path aliases to simplify imports from `src/lexicons/index.js` (e.g., `import { app } from '#lexicons'`). + +### Server Creation + +The `createServer` function now comes from `@atproto/xrpc-server` directly, not from generated code: + +```diff +- import { createServer } from './lexicon' ++ import { createServer } from '@atproto/xrpc-server' +``` + +The call signature changes slightly: + +```diff +- let server = createServer({ ++ const server = createServer([], { + validateResponse: config.debugMode, + payload: { ... }, + }) +``` + +Note the empty array `[]` as first argument. + +The `Server` type used in handler files also changes: + +```diff +- import { Server } from '../../../../lexicon' ++ import { Server } from '@atproto/xrpc-server' +``` + +The server's express router is accessed differently: + +```diff +- app.use(server.xrpc.router) ++ app.use(server.router) +``` + +### App Context / Initialization + +Replace `AtpAgent` with `Client` from `@atproto/lex`: + +```diff +- import { AtpAgent } from '@atproto/api' ++ import { Client } from '@atproto/lex' +``` + +In context/config files, rename fields: + +```diff +- searchAgent: AtpAgent | undefined ++ searchClient: Client | undefined +``` + +Client instantiation: + +```diff +- const myServiceAgent = config.serviceUrl +- ? new AtpAgent({ service: config.serviceUrl }) +- : undefined +- if (myServiceAgent && config.serviceApiKey) { +- myServiceAgent.api.setHeader('authorization', `Bearer ${config.serviceApiKey}`) +- } ++ const myServiceClient = config.serviceUrl ++ ? new Client({ ++ service: config.serviceUrl, ++ headers: config.serviceApiKey ++ ? { authorization: `Bearer ${config.serviceApiKey}` } ++ : undefined, ++ }) ++ : undefined +``` + +Headers are passed directly in the `Client` constructor options rather than being set imperatively after construction. + +### Type Aliases File (if applicable) + +The recommended pattern is to import from the generated code directly where needed, using import aliases. However, if the project contains a centralized types file that re-exports types from generated schemas, update it to import from the new generated code. + +```typescript +import { app, chat, com } from '../lexicons/index.js' + +// Type aliases +export type PostRecord = app.bsky.feed.post.Main +export type PostView = app.bsky.feed.defs.PostView +export type Label = com.atproto.label.defs.Label +export type StrongRef = com.atproto.repo.strongRef.Main + +// Type guard aliases +export const isPostRecord = app.bsky.feed.post.$matches +export const isImagesEmbed = app.bsky.embed.images.$matches + +// Validation aliases +export const parseStrongRef = com.atproto.repo.strongRef.$safeParse +``` + +The pattern is consistent: + +- **Type**: `export type Foo = namespace.path.TypeName` +- **Type guard**: `export const isFoo = namespace.path.$matches` +- **Validation**: `export const parseFoo = namespace.path.$safeParse` + +Types for the "main" definition of a record/object use `.Main`, sub-definitions use their specific name (e.g., `.ReplyRef`, `.ViewRecord`). + +## 3. Endpoint Handlers Registration + +The old pattern used method-chain registration on the server object. The new pattern uses `server.add()` with the schema object: + +```diff +- server.app.bsky.feed.getAuthorFeed({ ++ server.add(app.bsky.feed.getAuthorFeed, { + auth: ctx.authVerifier.optionalStandardOrRole, + handler: async ({ params, auth, req }) => { ... }, + }) +``` + +```diff +- server.com.atproto.identity.resolveHandle(async ({ req, params }) => { ++ server.add(com.atproto.identity.resolveHandle, async ({ params }) => { +``` + +The schema object is always imported from the generated `lexicons/` directory. + +### Handler Return Type Safety + +When handlers return JSON responses, use `'application/json' as const` for the encoding field to satisfy the return type: + +```typescript +return { + encoding: 'application/json' as const, + body: { preferences }, +} +``` + +Without the `as const`, TypeScript widens the string literal type and the handler's return type won't match. This is a type-level change only. + +Alternatively, a `satisfies` clause can be used to ensure the returned object matches the expected schema output type: + +```typescript +return { + encoding: 'application/json', + body: { preferences }, +} satisfies app.bsky.actor.getPreferences.$Output +``` + +### LXM References in Handlers + +Inside handlers, access the `$lxm` property from the schema for auth/proxy computations: + +```typescript +const lxm = app.bsky.actor.getPreferences.$lxm +const aud = computeProxyTo(ctx, req, lxm) +permissions.assertRpc({ aud, lxm }) +``` + +### Query/Procedure Parameter Types + +Replace old codegen type imports with `$Params` on the schema: + +```diff +- import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' ++ import { app } from '../../../../lexicons/index.js' + +- type Params = QueryParams ++ type Params = app.bsky.feed.getAuthorFeed.$Params +``` + +### Output Types + +Use `$Output` for response type annotations with `satisfies`: + +```typescript +return { + encoding: 'application/json' as const, + body: { actor, relationships }, +} satisfies app.bsky.graph.getRelationships.$Output +``` + +### Record/Object Types + +Replace individual type imports with namespace-based access: + +```diff +- import { Record as PostRecord } from '../lexicon/types/app/bsky/feed/post' ++ // Use the types file or direct namespace access: ++ import { PostRecord } from './types.js' ++ // or: app.bsky.feed.post.Main +``` + +### Type Aliases for Defs + +When code uses types from `defs` files, reference them through the namespace: + +```diff +- import { StatusAttr } from '../../lexicon/types/com/atproto/admin/defs' ++ // Use namespace access: ++ type StatusAttr = com.atproto.admin.defs.StatusAttr +``` + +```diff +- type CodeDetail = SomeCustomType ++ type CodeDetail = com.atproto.server.defs.InviteCode ++ type CodeUse = com.atproto.server.defs.InviteCodeUse +``` + +Whenever a new object with a `$type` needs to be constructed, use the `$build()` method on the schema: + +```typescript +const code = com.atproto.server.defs.inviteCode.$build({ + code: invite.code, + available: invite.availableUses - invite.uses.length, + disabled: invite.disabled === 1, + forAccount: invite.forUser, + createdBy: invite.createdBy, + createdAt: invite.createdAt, + uses: invite.uses, +}) +``` + +Otherwise, for plain data objects that don't require a `$type`, just use the namespace types for type annotations without changing the construction logic: + +```typescript +const code: com.atproto.server.defs.InviteCode = { + code: invite.code, + available: invite.availableUses - invite.uses.length, + disabled: invite.disabled === 1, + forAccount: invite.forUser, + createdBy: invite.createdBy, + createdAt: invite.createdAt, + uses: invite.uses, +} +``` + +### Token Values + +For accessing lexicon "token" string constants: + +```diff +- import { CURATELIST, MODLIST } from '../../../../lexicon/types/app/bsky/graph/defs' ++ import { app } from '../../../../lexicons/index.js' ++ const CURATELIST = app.bsky.graph.defs.curatelist.value ++ const MODLIST = app.bsky.graph.defs.modlist.value +``` + +### NSID String Constants + +The old `ids` object (e.g., `ids.AppBskyFeedPost`) is replaced by `$type` on the schema: + +```diff +- import { ids } from '../../../lexicon/lexicons' +- if (uri.collection === ids.AppBskyGraphList) { ++ import { app } from '../../../lexicons/index.js' ++ if (uri.collection === app.bsky.graph.list.$type) { +``` + +For LXM (lexicon method) checks in auth: + +```diff +- method === ids.AppBskyFeedGetFeedSkeleton ++ method === app.bsky.feed.getFeedSkeleton.$lxm +``` + +For simple comparisons in utility code, plain string literals are also acceptable: + +```diff +- if (uri.collection === ids.AppBskyFeedPost) { ++ if (uri.collection === 'app.bsky.feed.post') { +``` + +## 4. XRPC Client Calls + +### Using `Client.xrpc()` + +Replace `AtpAgent` API calls with `Client.xrpc()`: + +```diff +- const res = await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton( +- { viewer: params.hydrateCtx.viewer, relativeToDid }, +- { headers: params.headers }, +- ) +- return { +- suggestedDids: res.data.actors.map((a) => a.did), +- headers: res.headers, +- } ++ const res = await ctx.suggestionsClient.xrpc( ++ app.bsky.unspecced.getSuggestionsSkeleton, ++ { ++ params: { viewer: params.hydrateCtx.viewer, relativeToDid }, ++ headers: params.headers, ++ }, ++ ) ++ return { ++ suggestedDids: res.body.actors.map((a) => a.did), ++ contentLanguage: res.headers.get('content-language') ?? undefined, ++ } +``` + +Key differences: + +- Response data is on `.body` (not `.data`) +- Response headers use the standard `Headers` API (`.get()`) +- Parameters go in a `params` sub-object +- Procedure input goes in an `input` sub-object (not used in this example) + +### Using `Client.call()` + +Calls that only need the response body, and are happy letting any error propagate, can use the simpler `call()` method: + +```typescript +const body = await ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestionsSkeleton, + // "params" for Queries, "input" for Procedures: + { viewer: params.hydrateCtx.viewer, relativeToDid }, + // Optional additional options (see API reference): + { + headers, + signal, + }, +) +``` + +### Using `xrpcSafe()` for Error Handling + +For calls where you want to handle errors without exceptions, use `xrpcSafe()`: + +```diff +- import { AtpAgent } from '@atproto/api' +- import { ResponseType, XRPCError } from '@atproto/xrpc' ++ import { xrpcSafe } from '@atproto/lex' + +- const agent = new AtpAgent({ service: fgEndpoint }) +- try { +- const result = await agent.api.app.bsky.feed.getFeedSkeleton( +- { feed, limit, cursor }, +- { headers }, +- ) +- skeleton = result.data +- } catch (err) { +- if (err instanceof AppBskyFeedGetFeedSkeleton.UnknownFeedError) { ... } +- if (err instanceof XRPCError) { +- if (err.status === ResponseType.Unknown) { ... } +- if (err.status === ResponseType.InvalidResponse) { ... } +- } +- throw err +- } ++ const result = await xrpcSafe(fgEndpoint, app.bsky.feed.getFeedSkeleton, { ++ headers, ++ params: { feed, limit, cursor }, ++ }) ++ if (!result.success) { ++ if (result.matchesSchemaErrors()) { ++ throw new InvalidRequestError(result.message, result.error) ++ } ++ if (result.error === 'InternalServerError') { ... } ++ if (result.error === 'UpstreamFailure') { ... } ++ throw result.reason ++ } ++ // result.body is the typed response +``` + +### XrpcError + +Replace `XRPCError` from `@atproto/xrpc` with `XrpcError` from `@atproto/lex`: + +```diff +- import { XRPCError } from '@atproto/xrpc' ++ import { XrpcError } from '@atproto/lex' +``` + +## 5. Type Strictness (Branded Types) + +`@atproto/lex` exports branded string types that improve type safety. Apply these at type boundaries — function signatures, interface fields, database schema types — while keeping runtime code unchanged where possible. + +### `DidString` + +```diff +- did: string ++ did: DidString +``` + +```diff +- iss: string ++ iss: DidString | `${DidString}#${string}` +``` + +Use the type guard instead of string prefix checks: + +```diff +- if (typeof iss !== 'string' || !iss.startsWith('did:')) { ++ if (typeof iss !== 'string' || !isDidString(iss)) { +``` + +Import from `@atproto/lex`: + +```typescript +import { DidString, isDidString } from '@atproto/lex' +``` + +### `HandleString` + +```diff +- handle: string ++ handle: HandleString +``` + +```typescript +import { HandleString, isHandleString } from '@atproto/lex' +``` + +### `AtIdentifierString` + +For parameters that accept either a DID or a handle: + +```diff +- handleOrDid: string ++ handleOrDid: AtIdentifierString +``` + +Use the guard before passing to functions that require a specific type: + +```typescript +import { AtIdentifierString, isAtIdentifierString } from '@atproto/lex' + +if (!isAtIdentifierString(actor)) { + throw new InvalidRequestError('Invalid actor identifier') +} +const account = await getAccount(actor) +``` + +### `AtUriString` + +Apply to URI fields coming from data plane responses: + +```diff +- post: { uri: item.uri, cid: item.cid || undefined }, ++ post: { uri: item.uri as AtUriString, cid: item.cid || undefined }, +``` + +### `Cid` + +Replace `CID` from `multiformats` with `Cid` from `@atproto/lex-data`: + +```diff +- import { CID } from 'multiformats/cid' ++ import { Cid, parseCid } from '@atproto/lex-data' +``` + +Or import from `@atproto/lex` directly: + +```typescript +import { Cid } from '@atproto/lex' +``` + +### `DatetimeString` + +For datetime fields in database schemas and interfaces: + +```diff +- indexedAt: string ++ indexedAt: DatetimeString +``` + +### Type Narrowing at Data Boundaries + +When data comes from external sources (protobuf, data plane, Kysely queries), cast to branded types at the boundary: + +```diff +- suggestedDids: dids, ++ suggestedDids: dids as DidString[], +``` + +```diff +- qb.where('actor.did', '=', filter.sub!) ++ qb.where('actor.did', '=', filter.sub! as DidString) +``` + +This pattern is common when Kysely query builders need branded types that the query parameter doesn't naturally have. + +### `HeadersMap` + +Replace `Record` headers with proper `Headers` type: + +```diff +- import { HeadersMap } from '@atproto/xrpc' ++ import { Headers as HeadersMap } from '@atproto/xrpc-server' +``` + +> [!NOTE] +> +> `Headers` from `@atproto/xrpc-server` conflicts with the standard `Headers` type, so we alias it as `HeadersMap` to avoid confusion. + +Response headers from `xrpc()` calls use the standard `Headers` API: + +```diff +- result.headers['content-language'] ++ result.headers.get('content-language') +``` + +## 6. Data Utilities + +### JSON/Lex Parsing + +Replace `jsonStringToLex` from `@atproto/lexicon` with `lexParse` from `@atproto/lex`: + +```diff +- import { jsonStringToLex } from '@atproto/lexicon' ++ import { lexParse } from '@atproto/lex' + +- const parsed = jsonStringToLex( +- Buffer.from(payload).toString('utf8'), +- ) as SubjectActivitySubscription ++ const parsed = lexParse( ++ Buffer.from(payload).toString('utf8'), ++ ) +``` + +`lexParse` accepts a type parameter, eliminating the need for `as` casts. + +### Datetime Strings + +Replace `new Date().toISOString()` with branded datetime utilities for AT Protocol datetime fields: + +```diff +- createdAt: new Date().toISOString(), ++ createdAt: currentDatetimeString(), +``` + +For converting an existing `Date` object: + +```diff +- indexedAt: someDate.toISOString(), ++ indexedAt: toDatetimeString(someDate), +``` + +```typescript +import { toDatetimeString, currentDatetimeString } from '@atproto/lex' +``` + +### BlobRef + +The old `BlobRef` class from `@atproto/lexicon` is replaced by a simple interface from `@atproto/lex-data`. It is no longer a class, so `instanceof` checks are not possible anymore. Instead, use type guards to check if an object is a `BlobRef`: + +```diff +- import { BlobRef } from '@atproto/lexicon' ++ import { BlobRef } from '@atproto/lex-data' + +- export const cidFromBlobJson = (json: BlobRef) => { +- if (json instanceof BlobRef) { +- return json.ref.toString() +- } +- if (json['$type'] === 'blob') { +- return (json['ref']?.['$link'] ?? '') as string +- } +- return (json['cid'] ?? '') as string +- } ++ export const cidFromBlobJson = (json: BlobRef): string => { ++ return json.ref.toString() ++ } +``` + +```diff +- if (value instanceof BlobRef) { ... } ++ if (isBlobRef(value)) { ... } +``` + +### Legacy BlobRefs + +Legacy blob references (`{ cid: string, mimeType: string }`) are automatically handled based on the **strict mode** setting. When `strict: false`, both standard and legacy blob formats are accepted. When `strict: true` (the default), only standard `TypedBlobRef` format is accepted. + +```typescript +import { + TypedBlobRef, + LegacyBlobRef, + isTypedBlobRef, + isLegacyBlobRef, +} from '@atproto/lex-data' + +// Check for standard BlobRef +if (isTypedBlobRef(value)) { + console.log(value.ref.toString()) +} + +// Check for legacy format +if (isLegacyBlobRef(value)) { + console.log(value.cid) +} +``` + +New utility functions are available for working with both formats: + +```typescript +import { + BlobRef, + getBlobCid, + getBlobCidString, + getBlobMime, + getBlobSize, +} from '@atproto/lex-data' + +declare const blobRef: BlobRef // TypedBlobRef | LegacyBlobRef + +const cid = getBlobCid(blobRef) // Returns Cid object +const cidString = getBlobCidString(blobRef) // Returns string (optimized) +const mimeType = getBlobMime(blobRef) +const size = getBlobSize(blobRef) // Returns number | undefined (legacy refs don't have size) +``` + +### Strict Mode in Validation + +All schema validation methods (`$parse`, `$safeParse`, `$validate`, `$safeValidate`) accept an optional `{ strict }` option that controls validation behavior uniformly across both parse and validate modes: + +**Strict mode (`strict: true`, the default):** + +- Datetime strings must have proper timezone information +- Blob MIME types and size constraints are enforced +- Only raw CIDs are allowed in blob references +- Legacy blob references are rejected + +**Non-strict mode (`strict: false`):** + +- Datetime strings without timezones are accepted +- Blob MIME type and size constraints are not enforced +- Any valid CID is allowed in blob references +- Legacy blob references are accepted + +```typescript +// Default strict validation +const result1 = schema.$safeParse(data) // strict: true by default + +// Explicit strict validation +const result2 = schema.$safeParse(data, { strict: true }) + +// Non-strict validation (lenient) +const result3 = schema.$safeParse(data, { strict: false }) + +// Applies to all validation methods +schema.$validate(data, { strict: false }) +schema.$parse(data, { strict: false }) +schema.$safeValidate(data, { strict: false }) +``` + +The `Client` class has a `strictResponseProcessing` option that controls the default strict mode for all XRPC calls: + +```typescript +const client = new Client(session, { + strictResponseProcessing: false, // Use non-strict mode for all calls +}) +``` + +When `strictResponseProcessing: false`, response validation will use `strict: false`, which means legacy blobs and other lenient data formats are automatically accepted. Individual calls can override this with per-call options. + +### Lex Stringify + +```diff +- import { stringifyLex } from '@atproto/lexicon' ++ import { lexStringify } from '@atproto/lex' +``` + +## 7. Schema Validation and Type Guards + +### `$matches()` — Validates and Narrows Unknown Data + +Use `$matches()` when the data has not been pre-validated (e.g., it comes from an external source, or you need full runtime validation): + +```diff +- import { isRepoRef } from '../../../../lexicon/types/com/atproto/admin/defs' +- if (isRepoRef(subject)) { ... } ++ if (com.atproto.admin.defs.repoRef.$matches(subject)) { ... } +``` + +```diff +- repost: isSkeletonReasonRepost(item.reason) ? ... : undefined, ++ repost: app.bsky.feed.defs.skeletonReasonRepost.$matches(item.reason) ? ... : undefined, +``` + +### `$isTypeOf` — Discriminates Pre-Validated Data by `$type` + +Use `$isTypeOf` when the data is already validated and you only need to discriminate based on the `$type` property. This is faster than `$matches()` because it skips validation. Common in `.find()` and `.filter()` callbacks on arrays of already-parsed preference objects or union members: + +```diff +- const personalDetailsPref = prefs.find( +- (pref) => pref.$type === 'app.bsky.actor.defs#personalDetailsPref' +- ) ++ const personalDetailsPref = prefs.find( ++ app.bsky.actor.defs.personalDetailsPref.$isTypeOf, ++ ) +``` + +`$isTypeOf` is a type predicate function, so TypeScript narrows the type automatically when used in conditionals or `.find()`. + +### `$build()` — Constructs Typed Objects + +Use `$build()` instead of manually setting `$type`: + +```diff +- return { +- $type: 'app.bsky.graph.defs#relationship', +- did, +- following: subject.following, +- } ++ return app.bsky.graph.defs.relationship.$build({ ++ did, ++ following: subject.following, ++ }) +``` + +```diff +- prefs.push({ +- $type: 'app.bsky.actor.defs#declaredAgePref', +- isOverAge13: age >= 13, +- isOverAge16: age >= 16, +- isOverAge18: age >= 18, +- }) ++ prefs.push( ++ app.bsky.actor.defs.declaredAgePref.$build({ ++ isOverAge13: age >= 13, ++ isOverAge16: age >= 16, ++ isOverAge18: age >= 18, ++ }), ++ ) +``` + +`$build()` automatically sets the `$type` field and returns a properly typed object. + +## Tests + +Tests still rely exclusively on `@atproto/api`. When tests previously imported from the old `src/lexicon/` directory, redirect those imports to `@atproto/api`: + +```diff +- import { ids } from '../../src/lexicon/lexicons' +- import { RepoRef, isRepoRef } from '../../src/lexicon/types/com/atproto/admin/defs' +- import { $Typed } from '../../src/lexicon/util' ++ import { $Typed, AtpAgent, ComAtprotoAdminDefs, ids } from '@atproto/api' +``` + +Do not change how tests make XRPC calls — they continue to use `AtpAgent` from `@atproto/api`. This allows to ensure that the refactor does not break existing functionality at runtime. Tests will be migrated to `@atproto/lex` in a separate phase after all service code has been lexified. + +## Common Pitfalls + +1. **`$type` vs `$lxm`**: Use `$type` for record/object type strings (e.g., `app.bsky.feed.post.$type` = `'app.bsky.feed.post'`). Use `$lxm` for XRPC method identifiers used in auth checks and proxy routing. `$nsid` is also available for the raw NSID string if needed, this is especially useful for lexicon defs that don't have a `main` type but still need to reference their NSID. + +2. **`$matches` vs `$isTypeOf`**: Use `$matches()` when data needs validation (unknown input). Use `$isTypeOf` when data is already validated and you just need to check the `$type` tag (e.g., union discrimination, filtering an array of pre-parsed objects). + +3. **Branded type casts at boundaries**: Data from protobuf/data plane/Kysely returns plain strings. Cast to branded types (`as DidString`, `as AtUriString`) at the point where data enters the typed domain. Avoid `assert()` — use `as` casts at known-safe boundaries instead. + +4. **`'application/json' as const`**: Handler return values need `as const` on the encoding string literal to satisfy the return type. Without it, TypeScript widens the type. + +5. **Response header changes**: Old `AtpAgent` returned headers as a plain object with string indexing. New `Client`/`xrpc` returns standard `Headers` objects requiring `.get()`. + +6. **`@atproto/lex-data` sub-export**: `Cid`, `parseCid`, and `BlobRef` are available from `@atproto/lex-data` for files that only need data types without the full `@atproto/lex` package. Both import paths work. + +7. **Prefer `@atproto/lex` imports** over `@atproto/syntax` when both export the same symbol (e.g., `DidString`, `AtUriString`). + +8. **Avoid `assert()` calls.** Use type guards (`isDidString()`, `isHandleString()`) with conditional logic rather than assertions. + +## Import Source Changes Summary + +| Before | After | +| ------------------------------------------------- | ---------------------------------------------------------------------------- | +| `@atproto/api` (`AtpAgent`) | `@atproto/lex` (`Client`) | +| `@atproto/lexicon` (`jsonStringToLex`, `BlobRef`) | `@atproto/lex` (`lexParse`, `BlobRef`) | +| `@atproto/lexicon` (`stringifyLex`) | `@atproto/lex` (`lexStringify`) | +| `@atproto/xrpc` (`HeadersMap`, `XRPCError`) | `@atproto/xrpc-server` (`Headers`), `@atproto/lex` (`XrpcError`, `xrpcSafe`) | +| `multiformats/cid` (`CID`) | `@atproto/lex` (`Cid`, `parseCid`) | +| `@atproto/syntax` (`DidString`, etc.) | `@atproto/lex` (`DidString`, `HandleString`, etc.) — prefer `@atproto/lex` | +| `../lexicon` (`Server`, `createServer`) | `@atproto/xrpc-server` (`Server`, `createServer`) | +| `../lexicon/lexicons` (`ids`) | `../lexicons/index.js` (`app`, `com`, `chat`) | +| `../lexicon/types/...` (types, guards) | `../lexicons/index.js` (namespace-qualified access) | diff --git a/.claude/skills/vitest-patterns/SKILL.md b/.claude/skills/vitest-patterns/SKILL.md new file mode 100644 index 00000000000..e1d0696879a --- /dev/null +++ b/.claude/skills/vitest-patterns/SKILL.md @@ -0,0 +1,247 @@ +--- +name: vitest-patterns +description: Patterns and conventions for writing vitest tests in this project. This skill should be used when writing new tests, adding test cases to existing test files, or reviewing test code for correctness. Trigger whenever the user asks to write tests, add test coverage, create test files, or mentions vitest/testing. +--- + +# Vitest Test Patterns + +Conventions and patterns for writing vitest tests in this codebase. + +## Imports + +Always import test utilities from `vitest`. Use named imports: + +```ts +import { assert, describe, expect, it, vi } from 'vitest' +``` + +Only import what you use. Add `vi` only when using mock functions. Add `assert` only when narrowing types. + +## Test Structure + +### Describe labels + +Pass the function/class under test directly as the `describe` label when possible. Use string labels for conceptual groupings: + +```ts +// Function reference as label (preferred when testing a single export) +describe(parseCid, () => { ... }) +describe(isLexValue, () => { ... }) + +// String label for conceptual groups or when testing multiple related behaviors +describe('roundtrip toBase64 <-> fromBase64', () => { ... }) +describe('isObject', () => { ... }) +``` + +### Grouping + +Nest logical groupings inside the top-level describe. Common groupings: + +```ts +describe(someFunction, () => { + describe('valid inputs', () => { ... }) + describe('invalid inputs', () => { ... }) + describe('edge cases', () => { ... }) +}) +``` + +For features with a default behavior and an override, cover both: + +```ts +describe('validateResponse', () => { + it('rejects invalid response body by default', ...) + it('accepts invalid response body when disabled', ...) + it('succeeds with valid response body when enabled', ...) +}) +``` + +For safety-critical code, group edge cases under a `'safety'` or `'edge cases'` describe: + +```ts +describe('safety', () => { + it('handles cyclic structures without infinite loops', () => { ... }) + it('handles deep structures without exceeding call stack', () => { ... }) +}) +``` + +## Parameterized Tests + +Use `for...of` loops over test case arrays instead of `it.each`: + +```ts +describe(isLexScalar, () => { + for (const { note, value, expected } of [ + { note: 'string', value: 'hello', expected: true }, + { note: 'boolean', value: true, expected: true }, + { note: 'null', value: null, expected: true }, + { note: 'number (float)', value: 3.14, expected: false }, + { note: 'undefined', value: undefined, expected: false }, + ]) { + it(note, () => { + expect(isLexScalar(value)).toBe(expected) + }) + } +}) +``` + +This also works for running the same test suite against multiple implementations: + +```ts +for (const utf8Len of [utf8LenNode!, utf8LenCompute!] as const) { + describe(utf8Len, () => { + it('computes utf8 string length', () => { + expect(utf8Len('a')).toBe(1) + }) + }) +} +``` + +## Assertions + +### Type narrowing with `assert` + +Use `assert()` from vitest for type narrowing and boolean checks. **Always prefer `assert(result.success)` over `expect(result.success).toBe(true)`** — the `assert` provides type narrowing in TypeScript, which allows the rest of the test to access narrowed properties without additional type guards. + +```ts +// Narrow to a specific type before further assertions +assert(err instanceof XrpcFetchError) +expect(err.cause).toBeInstanceOf(TypeError) + +// Discriminated union checks - PREFERRED +assert(result.success) +expect(result.body).toEqual({ value: 'hello' }) +// TypeScript now knows result.body exists + +assert(!result.success) +expect(result).toBeInstanceOf(XrpcResponseError) +// TypeScript now knows result has error properties + +// DON'T do this - it doesn't narrow types +expect(result.success).toBe(true) // ❌ No type narrowing +if (result.success) { + expect(result.body).toEqual({ value: 'hello' }) // Still need type guard +} +``` + +### Error assertions with `rejects.toSatisfy` + +For thrown errors, prefer `rejects.toSatisfy()` over `rejects.toThrow()` when you need multiple detailed assertions: + +```ts +await expect( + someAsyncFn(), +).rejects.toSatisfy((err) => { + assert(err instanceof SomeError) + expect(err.cause).toBeInstanceOf(TypeError) + expect(err.message).toContain('failed') + return true // must return true +}) +``` + +Always `return true` at the end of `toSatisfy` callbacks. + +For simple "it throws" checks, `toThrow()` is fine: + +```ts +expect(() => parseCid(invalidStr)).toThrow() +expect(() => cidForRawHash(new Uint8Array(31))).toThrow('Invalid SHA-256 hash length') +``` + +## Mock Functions + +### Use `vi.fn()` with type parameters + +When you need to inspect how a function was called, use `vi.fn()`: + +```ts +const fetchHandler = vi.fn(async () => + Response.json({ value: 'ok' }), +) + +await xrpc(fetchHandler, testQuery, { params: { limit: 25 } }) + +expect(fetchHandler).toHaveBeenCalledOnce() +const [path, init] = fetchHandler.mock.calls[0] +expect(path).toContain('/xrpc/io.example.testQuery') +``` + +When you don't need to inspect calls, use a plain typed function: + +```ts +const fetchHandler: FetchHandler = async () => Response.json({ value: 'hello' }) +``` + +## Test Fixtures + +Define reusable fixtures at the top of the file, outside describe blocks: + +```ts +const invalidCidStr = 'invalidcidstring' +const cborCidStr = 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a' +const cborCid = parseCid(cborCidStr, { flavor: 'cbor' }) +``` + +Keep fixtures minimal and focused on what the tests need. + +## TypeScript in Tests + +### Intentionally invalid arguments + +Use `// @ts-expect-error` with a description: + +```ts +await xrpc(fetchHandler, testQuery, { + // @ts-expect-error intentionally passing invalid params + params: { limit: 'not-a-number' }, + validateRequest: true, +}) +``` + +## Global Stubbing + +When testing code that uses a global, temporarily replace it and restore in a `finally` block: + +```ts +it('throws TypeError when fetch is not available', () => { + const originalFetch = globalThis.fetch + try { + // @ts-expect-error removing fetch to simulate missing environment + globalThis.fetch = undefined + expect(() => buildAgent({ service: 'https://example.com' })).toThrow(TypeError) + } finally { + globalThis.fetch = originalFetch + } +}) +``` + +Use `try/finally` (not `beforeEach`/`afterEach`) when the stub is scoped to a single test. + +## Roundtrip Tests + +When testing encode/decode or serialize/deserialize pairs, add a dedicated roundtrip describe: + +```ts +describe('roundtrip toBase64 <-> fromBase64', () => { + it('roundtrips empty array', () => { + const original = new Uint8Array(0) + expect(ui8Equals(fromBase64(toBase64(original)), original)).toBe(true) + }) + + it('roundtrips all byte values', () => { + const allBytes = new Uint8Array(256) + for (let i = 0; i < 256; i++) allBytes[i] = i + expect(ui8Equals(fromBase64(toBase64(allBytes)), allBytes)).toBe(true) + }) +}) +``` + +## Workflow + +- Do not worry about code formatting or lint issues. The user will review changes in their editor, which applies formatting automatically on save. +- Do not commit test changes. Leave them as unstaged modifications for the user to review. + +## Running Tests + +```bash +pnpm exec vitest run path/to/file.test.ts +``` diff --git a/.eslintignore b/.eslintignore index 0d95424d551..fb44b2727ed 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,17 +7,18 @@ packages/bsync/src/proto # codegen packages/api/src/client -packages/lexicon-resolver/src/client -packages/bsky/src/lexicon -packages/pds/src/lexicon packages/ozone/src/lexicon # @atproto/lex +packages/lexicon-resolver/src/lexicons packages/lex/*/src/lexicons packages/lex/*/tests/lexicons packages/oauth/oauth-client-browser-example/src/lexicons +packages/pds/src/lexicons +packages/bsky/src/lexicons +packages/sync/src/lexicons # others +packages/api/src/moderation/const/labels.ts packages/oauth/*/src/locales/*/messages.ts -packages/oauth/oauth-provider-frontend/src/routeTree.gen.ts packages/oauth/oauth-client-expo/android/build diff --git a/.eslintrc b/.eslintrc index 2c2a6417af0..3f5f750aeda 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,6 +33,9 @@ "distinctGroup": true, "alphabetize": { "order": "asc" }, "newlines-between": "never", + "pathGroups": [ + { "pattern": "#/**", "group": "parent", "position": "before" } + ], "groups": [ "builtin", "external", diff --git a/.gitattributes b/.gitattributes index 5732dfab476..da3637dd037 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,10 +4,10 @@ packages/bsync/src/proto/** linguist-generated=true # codegen packages/api/src/client/** linguist-generated=true -packages/lexicon-resolver/src/client/** linguist-generated=true -packages/bsky/src/lexicon/** linguist-generated=true -packages/pds/src/lexicon/** linguist-generated=true packages/ozone/src/lexicon/** linguist-generated=true +# @atproto/lex +packages/lexicon-resolver/src/lexicons/** linguist-generated=true + # i18n packages/oauth/oauth-provider-ui/src/locales/**/messages.po linguist-generated=true diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml index e0526ac2241..e7650d23fcb 100644 --- a/.github/workflows/build-and-push-bsky-aws.yaml +++ b/.github/workflows/build-and-push-bsky-aws.yaml @@ -3,6 +3,8 @@ on: push: branches: - main + - msi/pds-lexification + env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/.github/workflows/build-and-push-bsync-aws.yaml b/.github/workflows/build-and-push-bsync-aws.yaml index cfd0970f573..fb8d1326a3b 100644 --- a/.github/workflows/build-and-push-bsync-aws.yaml +++ b/.github/workflows/build-and-push-bsync-aws.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/.github/workflows/build-and-push-bsync-ghcr.yaml b/.github/workflows/build-and-push-bsync-ghcr.yaml index a22718cd00d..c5dd8745217 100644 --- a/.github/workflows/build-and-push-bsync-ghcr.yaml +++ b/.github/workflows/build-and-push-bsync-ghcr.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + env: REGISTRY: ghcr.io USERNAME: ${{ github.actor }} diff --git a/.github/workflows/build-and-push-ozone-aws.yaml b/.github/workflows/build-and-push-ozone-aws.yaml index ca8750a182b..5f0a7338ada 100644 --- a/.github/workflows/build-and-push-ozone-aws.yaml +++ b/.github/workflows/build-and-push-ozone-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - divy/ozone-metadata + env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/.github/workflows/build-and-push-ozone-ghcr.yaml b/.github/workflows/build-and-push-ozone-ghcr.yaml index 1069658ac69..5fb77558752 100644 --- a/.github/workflows/build-and-push-ozone-ghcr.yaml +++ b/.github/workflows/build-and-push-ozone-ghcr.yaml @@ -3,6 +3,7 @@ on: push: branches: - main + env: REGISTRY: ghcr.io USERNAME: ${{ github.actor }} diff --git a/.github/workflows/build-and-push-pds-aws.yaml b/.github/workflows/build-and-push-pds-aws.yaml index 803a431cd92..88d4518ef11 100644 --- a/.github/workflows/build-and-push-pds-aws.yaml +++ b/.github/workflows/build-and-push-pds-aws.yaml @@ -3,6 +3,8 @@ on: push: branches: - main + - msi/pds-lexification + env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/.github/workflows/claude.yaml b/.github/workflows/claude.yaml new file mode 100644 index 00000000000..247217fe2d5 --- /dev/null +++ b/.github/workflows/claude.yaml @@ -0,0 +1,54 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + + # NOTE(sfn): we can add a custom system prompt here + + claude_args: | + --model claude-opus-4-5-20251101 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 6406dd53994..9cccb9e6d29 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -15,25 +15,25 @@ env: jobs: build: - name: Build & Publish + name: Publish if: github.repository == 'bluesky-social/atproto' - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # https://github.com/actions/setup-node/issues/531#issuecomment-2960522861 - - name: Install Node.js - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: + cache: pnpm node-version-file: '.nvmrc' registry-url: 'https://registry.npmjs.org' - - name: Enable Corepack - run: corepack enable - - name: Configure Dependency Cache - uses: actions/setup-node@v6 - with: - cache: 'pnpm' - - run: npm i -g npm@latest - - run: pnpm i --frozen-lockfile + # Temporary workaround for an issue with Node.js v22 + # https://github.com/npm/cli/issues/9151 + # https://github.com/npm/cli/pull/9152 + - run: npm install --global npm@11.11.1 + - run: npm install --global npm@latest + - run: pnpm install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: true - name: Publish uses: changesets/action@v1 id: changesets diff --git a/.github/workflows/repo.yaml b/.github/workflows/repo.yaml index 9db5b898a66..2f9ab26ec6b 100644 --- a/.github/workflows/repo.yaml +++ b/.github/workflows/repo.yaml @@ -1,4 +1,4 @@ -name: Test +name: Repository CI on: pull_request: @@ -12,32 +12,23 @@ concurrency: jobs: build: name: Build - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # https://github.com/actions/setup-node/issues/531#issuecomment-2960522861 - - name: Install Node.js - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: + cache: pnpm node-version-file: '.nvmrc' - - name: Enable Corepack - run: corepack enable - - name: Configure Dependency Cache - uses: actions/setup-node@v4 - with: - cache: 'pnpm' - - name: Get current month - run: echo "CURRENT_MONTH=$(date +'%Y-%m')" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Cache Puppeteer browser binaries - with: - path: ~/.cache - key: ${{ env.CURRENT_MONTH }}-${{ runner.os }}-${{ runner.arch }} - - run: pnpm i --frozen-lockfile + - run: pnpm install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: true + - run: pnpm build - uses: actions/upload-artifact@v4 with: name: dist + retention-days: 2 path: | packages/*/dist packages/*/*/dist @@ -45,66 +36,83 @@ jobs: packages/lex/*/tests/lexicons packages/oauth/oauth-client-browser-example/src/lexicons packages/oauth/*/src/locales/*/messages.ts - retention-days: 1 + packages/api/src/moderation/const/labels.ts + packages/bsky/src/lexicons + packages/pds/src/lexicons + packages/sync/src/lexicons + + changeset: + name: Changeset + runs-on: ubuntu-latest + if: false + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed for git diff against base branch + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + cache: pnpm + node-version-file: '.nvmrc' + - run: pnpm install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: true + - run: pnpm changeset status --since=origin/${{ github.base_ref }} + test: name: Test needs: build + runs-on: ubuntu-22.04 + # Puppeteer does not work in recent Ubuntu versions without a workaround due + # to sandboxing issues. Using "ubuntu-latest" results in the following + # error: + # + # No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux + # distro that has disabled unprivileged user namespaces with AppArmor, see + # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md. + # Otherwise see + # https://chromium.googlesource.com/chromium/src/+/main/docs/linux/suid_sandbox_development.md + # for more information on developing with the (older) SUID sandbox. If you + # want to live dangerously and need an immediate workaround, you can try + # using --no-sandbox. strategy: fail-fast: false matrix: shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8] - runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - # https://github.com/actions/setup-node/issues/531#issuecomment-2960522861 - - name: Install Node.js - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: + cache: pnpm node-version-file: '.nvmrc' - - name: Enable Corepack - run: corepack enable - - name: Configure Dependency Cache - uses: actions/setup-node@v4 - with: - cache: 'pnpm' - - name: Get current month - run: echo "CURRENT_MONTH=$(date +'%Y-%m')" >> $GITHUB_ENV + - run: echo "CURRENT_MONTH=$(date +'%Y-%m')" >> $GITHUB_ENV - uses: actions/cache@v4 name: Cache Puppeteer browser binaries with: - path: ~/.cache + path: ~/.cache/puppeteer key: ${{ env.CURRENT_MONTH }}-${{ runner.os }}-${{ runner.arch }} - - run: pnpm i --frozen-lockfile + - run: pnpm install --frozen-lockfile - uses: actions/download-artifact@v4 with: name: dist path: packages - run: pnpm test:withFlags --maxWorkers=1 --shard=${{ matrix.shard }} --passWithNoTests + verify: name: Verify needs: build - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # https://github.com/actions/setup-node/issues/531#issuecomment-2960522861 - - name: Install Node.js - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: + cache: pnpm node-version-file: '.nvmrc' - - name: Enable Corepack - run: corepack enable - - name: Configure Dependency Cache - uses: actions/setup-node@v4 - with: - cache: 'pnpm' - - name: Get current month - run: echo "CURRENT_MONTH=$(date +'%Y-%m')" >> $GITHUB_ENV - - uses: actions/cache@v4 - name: Cache Puppeteer browser binaries - with: - path: ~/.cache - key: ${{ env.CURRENT_MONTH }}-${{ runner.os }}-${{ runner.arch }} - - run: pnpm i --frozen-lockfile + - run: pnpm install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: true - uses: actions/download-artifact@v4 with: name: dist diff --git a/.prettierignore b/.prettierignore index 7a4fab9c0d8..0ca46fdda8f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,17 +13,18 @@ packages/bsync/src/proto # codegen packages/api/src/client -packages/lexicon-resolver/src/client -packages/bsky/src/lexicon -packages/pds/src/lexicon packages/ozone/src/lexicon # @atproto/lex +packages/lexicon-resolver/src/lexicons packages/lex/*/src/lexicons packages/lex/*/tests/lexicons packages/oauth/oauth-client-browser-example/src/lexicons +packages/pds/src/lexicons +packages/bsky/src/lexicons +packages/sync/src/lexicons # others +packages/api/src/moderation/const/labels.ts packages/oauth/*/src/locales/*/messages.ts -packages/oauth/oauth-provider-frontend/src/routeTree.gen.ts packages/oauth/oauth-client-expo/android/build diff --git a/.vscode/settings.json b/.vscode/settings.json index 0b2a6c347e0..482e0e0adfc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "cSpell.language": "en,en-US", - "cSpell.userWords": [ + "cSpell.words": [ "algs", "appview", "atproto", diff --git a/Makefile b/Makefile index 9dc86066599..0f4a2a81045 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ help: ## Print info about all commands @echo "NOTE: dependencies between commands are not automatic. Eg, you must run 'deps' and 'build' first, and after any changes" .PHONY: build -build: ## Compile all modules +build: codegen ## Compile all modules pnpm build .PHONY: test diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json index e4a90384a99..8bbad85ff01 100644 --- a/lexicons/app/bsky/actor/defs.json +++ b/lexicons/app/bsky/actor/defs.json @@ -685,6 +685,10 @@ "description": "An optional embed associated with the status.", "refs": ["app.bsky.embed.external#view"] }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, "expiresAt": { "type": "string", "description": "The date when this status will expire. The application might choose to no longer return the status after expiration.", diff --git a/lexicons/app/bsky/actor/getSuggestions.json b/lexicons/app/bsky/actor/getSuggestions.json index 31e526520d7..070200d0354 100644 --- a/lexicons/app/bsky/actor/getSuggestions.json +++ b/lexicons/app/bsky/actor/getSuggestions.json @@ -33,6 +33,10 @@ }, "recId": { "type": "integer", + "description": "DEPRECATED: use recIdStr instead." + }, + "recIdStr": { + "type": "string", "description": "Snowflake for this recommendation, use when submitting recommendation events." } } diff --git a/lexicons/app/bsky/authViewAll.json b/lexicons/app/bsky/authViewAll.json index 63b069c1ed3..dace940055c 100644 --- a/lexicons/app/bsky/authViewAll.json +++ b/lexicons/app/bsky/authViewAll.json @@ -14,6 +14,7 @@ "resource": "rpc", "inheritAud": true, "lxm": [ + "app.bsky.actor.getPreferences", "app.bsky.actor.getProfile", "app.bsky.actor.getProfiles", "app.bsky.actor.getSuggestions", diff --git a/lexicons/app/bsky/embed/images.json b/lexicons/app/bsky/embed/images.json index c6b6fa20b5d..e8599ae6a2b 100644 --- a/lexicons/app/bsky/embed/images.json +++ b/lexicons/app/bsky/embed/images.json @@ -20,8 +20,9 @@ "properties": { "image": { "type": "blob", + "description": "The raw image file. May be up to 2 MB, formerly limited to 1 MB.", "accept": ["image/*"], - "maxSize": 1000000 + "maxSize": 2000000 }, "alt": { "type": "string", diff --git a/lexicons/app/bsky/feed/sendInteractions.json b/lexicons/app/bsky/feed/sendInteractions.json index c50fbbc0e17..5490e3cd7bc 100644 --- a/lexicons/app/bsky/feed/sendInteractions.json +++ b/lexicons/app/bsky/feed/sendInteractions.json @@ -11,6 +11,7 @@ "type": "object", "required": ["interactions"], "properties": { + "feed": { "type": "string", "format": "at-uri" }, "interactions": { "type": "array", "items": { diff --git a/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json b/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json index 454de1545bb..e4df85499ed 100644 --- a/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json +++ b/lexicons/app/bsky/graph/getSuggestedFollowsByActor.json @@ -25,14 +25,18 @@ "ref": "app.bsky.actor.defs#profileView" } }, + "recIdStr": { + "type": "string", + "description": "Snowflake for this recommendation, use when submitting recommendation events." + }, "isFallback": { "type": "boolean", - "description": "If true, response has fallen-back to generic results, and is not scoped using relativeToDid", + "description": "DEPRECATED, unused. Previously: if true, response has fallen-back to generic results, and is not scoped using relativeToDid", "default": false }, "recId": { "type": "integer", - "description": "Snowflake for this recommendation, use when submitting recommendation events." + "description": "DEPRECATED: use recIdStr instead." } } } diff --git a/lexicons/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.json b/lexicons/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.json similarity index 87% rename from lexicons/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.json rename to lexicons/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.json index 85553e33cf5..1303ac174d8 100644 --- a/lexicons/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.json +++ b/lexicons/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton", + "id": "app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton", "defs": { "main": { "type": "query", @@ -39,6 +39,10 @@ } }, "recId": { + "type": "string", + "description": "DEPRECATED: use recIdStr instead." + }, + "recIdStr": { "type": "string", "description": "Snowflake for this recommendation, use when submitting recommendation events." } diff --git a/lexicons/app/bsky/unspecced/getSuggestedOnboardingUsers.json b/lexicons/app/bsky/unspecced/getSuggestedOnboardingUsers.json index 7d5c29b7b30..24e45566ef0 100644 --- a/lexicons/app/bsky/unspecced/getSuggestedOnboardingUsers.json +++ b/lexicons/app/bsky/unspecced/getSuggestedOnboardingUsers.json @@ -34,6 +34,10 @@ } }, "recId": { + "type": "string", + "description": "DEPRECATED: use recIdStr instead." + }, + "recIdStr": { "type": "string", "description": "Snowflake for this recommendation, use when submitting recommendation events." } diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsers.json b/lexicons/app/bsky/unspecced/getSuggestedUsers.json index d3bc5649d5c..705e21c9d61 100644 --- a/lexicons/app/bsky/unspecced/getSuggestedUsers.json +++ b/lexicons/app/bsky/unspecced/getSuggestedUsers.json @@ -34,6 +34,10 @@ } }, "recId": { + "type": "string", + "description": "DEPRECATED: use recIdStr instead." + }, + "recIdStr": { "type": "string", "description": "Snowflake for this recommendation, use when submitting recommendation events." } diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsersForDiscover.json b/lexicons/app/bsky/unspecced/getSuggestedUsersForDiscover.json new file mode 100644 index 00000000000..7d0f289d122 --- /dev/null +++ b/lexicons/app/bsky/unspecced/getSuggestedUsersForDiscover.json @@ -0,0 +1,41 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getSuggestedUsersForDiscover", + "defs": { + "main": { + "type": "query", + "description": "Get a list of suggested users for the Discover page", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 25 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["actors"], + "properties": { + "actors": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileView" + } + }, + "recIdStr": { + "type": "string", + "description": "Snowflake for this recommendation, use when submitting recommendation events." + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.json b/lexicons/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.json new file mode 100644 index 00000000000..7f017ca5e63 --- /dev/null +++ b/lexicons/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.json @@ -0,0 +1,46 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getSuggestedUsersForDiscoverSkeleton", + "defs": { + "main": { + "type": "query", + "description": "Get a skeleton of suggested users for the Discover page. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedUsersForDiscover", + "parameters": { + "type": "params", + "properties": { + "viewer": { + "type": "string", + "format": "did", + "description": "DID of the account making the request (not included for public/unauthenticated queries)." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 25 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["dids"], + "properties": { + "dids": { + "type": "array", + "items": { + "type": "string", + "format": "did" + } + }, + "recIdStr": { + "type": "string", + "description": "Snowflake for this recommendation, use when submitting recommendation events." + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsersForExplore.json b/lexicons/app/bsky/unspecced/getSuggestedUsersForExplore.json new file mode 100644 index 00000000000..a37a21a7eb2 --- /dev/null +++ b/lexicons/app/bsky/unspecced/getSuggestedUsersForExplore.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getSuggestedUsersForExplore", + "defs": { + "main": { + "type": "query", + "description": "Get a list of suggested users for the Explore page", + "parameters": { + "type": "params", + "properties": { + "category": { + "type": "string", + "description": "Category of users to get suggestions for." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 25 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["actors"], + "properties": { + "actors": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileView" + } + }, + "recIdStr": { + "type": "string", + "description": "Snowflake for this recommendation, use when submitting recommendation events." + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.json b/lexicons/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.json new file mode 100644 index 00000000000..b3f3186e846 --- /dev/null +++ b/lexicons/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getSuggestedUsersForExploreSkeleton", + "defs": { + "main": { + "type": "query", + "description": "Get a skeleton of suggested users for the Explore page. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedUsersForExplore", + "parameters": { + "type": "params", + "properties": { + "viewer": { + "type": "string", + "format": "did", + "description": "DID of the account making the request (not included for public/unauthenticated queries)." + }, + "category": { + "type": "string", + "description": "Category of users to get suggestions for." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 25 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["dids"], + "properties": { + "dids": { + "type": "array", + "items": { + "type": "string", + "format": "did" + } + }, + "recIdStr": { + "type": "string", + "description": "Snowflake for this recommendation, use when submitting recommendation events." + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsersForSeeMore.json b/lexicons/app/bsky/unspecced/getSuggestedUsersForSeeMore.json new file mode 100644 index 00000000000..1938df7e555 --- /dev/null +++ b/lexicons/app/bsky/unspecced/getSuggestedUsersForSeeMore.json @@ -0,0 +1,45 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getSuggestedUsersForSeeMore", + "defs": { + "main": { + "type": "query", + "description": "Get a list of suggested users for the See More page", + "parameters": { + "type": "params", + "properties": { + "category": { + "type": "string", + "description": "Category of users to get suggestions for." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 25 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["actors"], + "properties": { + "actors": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileView" + } + }, + "recIdStr": { + "type": "string", + "description": "Snowflake for this recommendation, use when submitting recommendation events." + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.json b/lexicons/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.json new file mode 100644 index 00000000000..954b518d777 --- /dev/null +++ b/lexicons/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "app.bsky.unspecced.getSuggestedUsersForSeeMoreSkeleton", + "defs": { + "main": { + "type": "query", + "description": "Get a skeleton of suggested users for the See More page. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedUsersForSeeMore", + "parameters": { + "type": "params", + "properties": { + "viewer": { + "type": "string", + "format": "did", + "description": "DID of the account making the request (not included for public/unauthenticated queries)." + }, + "category": { + "type": "string", + "description": "Category of users to get suggestions for." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 25 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["dids"], + "properties": { + "dids": { + "type": "array", + "items": { + "type": "string", + "format": "did" + } + }, + "recIdStr": { + "type": "string", + "description": "Snowflake for this recommendation, use when submitting recommendation events." + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/unspecced/getSuggestedUsersSkeleton.json b/lexicons/app/bsky/unspecced/getSuggestedUsersSkeleton.json index 864eeab519e..3fed8dd95db 100644 --- a/lexicons/app/bsky/unspecced/getSuggestedUsersSkeleton.json +++ b/lexicons/app/bsky/unspecced/getSuggestedUsersSkeleton.json @@ -39,6 +39,10 @@ } }, "recId": { + "type": "string", + "description": "DEPRECATED: use recIdStr instead." + }, + "recIdStr": { "type": "string", "description": "Snowflake for this recommendation, use when submitting recommendation events." } diff --git a/lexicons/app/bsky/unspecced/getSuggestionsSkeleton.json b/lexicons/app/bsky/unspecced/getSuggestionsSkeleton.json index 658f2a11b2b..f97504c31d3 100644 --- a/lexicons/app/bsky/unspecced/getSuggestionsSkeleton.json +++ b/lexicons/app/bsky/unspecced/getSuggestionsSkeleton.json @@ -48,6 +48,10 @@ }, "recId": { "type": "integer", + "description": "DEPRECATED: use recIdStr instead." + }, + "recIdStr": { + "type": "string", "description": "Snowflake for this recommendation, use when submitting recommendation events." } } diff --git a/lexicons/chat/bsky/authFullChatClient.json b/lexicons/chat/bsky/authFullChatClient.json index 97867680b31..4de94a93063 100644 --- a/lexicons/chat/bsky/authFullChatClient.json +++ b/lexicons/chat/bsky/authFullChatClient.json @@ -17,10 +17,10 @@ "inheritAud": true, "lxm": [ "chat.bsky.actor.deleteAccount", + "chat.bsky.actor.exportAccountData", "chat.bsky.convo.acceptConvo", "chat.bsky.convo.addReaction", "chat.bsky.convo.deleteMessageForSelf", - "chat.bsky.convo.exportAccountData", "chat.bsky.convo.getConvo", "chat.bsky.convo.getConvoAvailability", "chat.bsky.convo.getConvoForMembers", diff --git a/lexicons/com/atproto/label/defs.json b/lexicons/com/atproto/label/defs.json index 6f4c1ab9dd0..26b6a3f9858 100644 --- a/lexicons/com/atproto/label/defs.json +++ b/lexicons/com/atproto/label/defs.json @@ -140,16 +140,13 @@ "type": "string", "knownValues": [ "!hide", - "!no-promote", "!warn", "!no-unauthenticated", - "dmca-violation", - "doxxing", "porn", "sexual", "nudity", - "nsfl", - "gore" + "graphic-media", + "bot" ] } } diff --git a/lexicons/tools/ozone/moderation/defs.json b/lexicons/tools/ozone/moderation/defs.json index b3074ff5f95..0ed8f27d736 100644 --- a/lexicons/tools/ozone/moderation/defs.json +++ b/lexicons/tools/ozone/moderation/defs.json @@ -38,6 +38,7 @@ "#modEventPriorityScore", "#ageAssuranceEvent", "#ageAssuranceOverrideEvent", + "#ageAssurancePurgeEvent", "#revokeAccountCredentialsEvent", "#scheduleTakedownEvent", "#cancelScheduledTakedownEvent" @@ -95,6 +96,7 @@ "#modEventPriorityScore", "#ageAssuranceEvent", "#ageAssuranceOverrideEvent", + "#ageAssurancePurgeEvent", "#revokeAccountCredentialsEvent", "#scheduleTakedownEvent", "#cancelScheduledTakedownEvent" @@ -587,6 +589,18 @@ } } }, + "ageAssurancePurgeEvent": { + "type": "object", + "description": "Purges all age assurance events for the subject. Only works on DID subjects. Moderator-only.", + "required": ["comment"], + "properties": { + "comment": { + "type": "string", + "minLength": 1, + "description": "Comment describing the reason for the purge." + } + } + }, "revokeAccountCredentialsEvent": { "type": "object", "description": "Account credentials revocation by moderators. Only works on DID subjects.", diff --git a/lexicons/tools/ozone/moderation/emitEvent.json b/lexicons/tools/ozone/moderation/emitEvent.json index ddce79211ae..256ef164242 100644 --- a/lexicons/tools/ozone/moderation/emitEvent.json +++ b/lexicons/tools/ozone/moderation/emitEvent.json @@ -35,6 +35,7 @@ "tools.ozone.moderation.defs#modEventPriorityScore", "tools.ozone.moderation.defs#ageAssuranceEvent", "tools.ozone.moderation.defs#ageAssuranceOverrideEvent", + "tools.ozone.moderation.defs#ageAssurancePurgeEvent", "tools.ozone.moderation.defs#revokeAccountCredentialsEvent", "tools.ozone.moderation.defs#scheduleTakedownEvent", "tools.ozone.moderation.defs#cancelScheduledTakedownEvent" diff --git a/package.json b/package.json index b19b2aee388..266b4ff40e6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "verify:lint": "pnpm lint", "verify:types": "tsc --build tsconfig.json", "format": "pnpm lint:fix && pnpm style:fix", - "precodegen": "pnpm run --recursive --stream --filter '@atproto/lex-cli...' build --force", + "precodegen": "pnpm run --recursive --stream --filter '@atproto/lex-cli...' --filter '@atproto/lex...' build --force", "codegen": "pnpm run --sort --recursive --stream --parallel codegen", "build": "pnpm run --sort --recursive --stream build", "i18n": "pnpm run --parallel i18n:extract", @@ -27,6 +27,7 @@ "dev:tsc": "tsc --build tsconfig.json --preserveWatchOutput --watch", "test": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm test --stream --recursive", "test:withFlags": "pnpm run test --", + "bench": "vitest bench --run", "changeset": "changeset", "release": "pnpm build && changeset publish", "version-packages": "changeset version && git add ." diff --git a/packages/api/.gitignore b/packages/api/.gitignore new file mode 100644 index 00000000000..be83884b87c --- /dev/null +++ b/packages/api/.gitignore @@ -0,0 +1,5 @@ +# @atproto/lex-cli +# src/client + +# ./scripts/generate-code.mjs +src/moderation/const/labels.ts diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index ce46a10d6fd..b43ef63b8bc 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,88 @@ # @atproto/api +## 0.19.8 + +### Patch Changes + +- [#4841](https://github.com/bluesky-social/atproto/pull/4841) [`55c3986`](https://github.com/bluesky-social/atproto/commit/55c39860c8fbf9747a6edec415f19c67f80c597f) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Make sure to include did and isMe checks on decideStatus default decision + +## 0.19.7 + +### Patch Changes + +- [#4555](https://github.com/bluesky-social/atproto/pull/4555) [`aa1763d`](https://github.com/bluesky-social/atproto/commit/aa1763df0f1bb46014ba6a416646a08c61d97950) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Hydrate mod labels on actor `status` record views + +- [#4823](https://github.com/bluesky-social/atproto/pull/4823) [`d8801e2`](https://github.com/bluesky-social/atproto/commit/d8801e2a17fe7062b7aa674475b384ead7518a17) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Increase max image upload size to 2 MB from 1 MB + +- [#4555](https://github.com/bluesky-social/atproto/pull/4555) [`aa1763d`](https://github.com/bluesky-social/atproto/commit/aa1763df0f1bb46014ba6a416646a08c61d97950) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Add `moderateStatus` method to eval labels on actor `status` views + +- Updated dependencies []: + - @atproto/common-web@0.4.20 + +## 0.19.6 + +### Patch Changes + +- [#4809](https://github.com/bluesky-social/atproto/pull/4809) [`2a5e2c2`](https://github.com/bluesky-social/atproto/commit/2a5e2c267f130df8eed87c9bdcb26e97841abc13) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Create new endpoints for suggested users + +- Updated dependencies [[`3711454`](https://github.com/bluesky-social/atproto/commit/371145432178b6c8c411f1289c266314cc7ec592)]: + - @atproto/syntax@0.5.3 + +## 0.19.5 + +### Patch Changes + +- [#4408](https://github.com/bluesky-social/atproto/pull/4408) [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Export internal `ids` + +- [#4408](https://github.com/bluesky-social/atproto/pull/4408) [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add "codegen" as part of the build + +- [#4408](https://github.com/bluesky-social/atproto/pull/4408) [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Type `did` and `assertDid` `Agent` properties to be typed as `DidString` + +- [#4791](https://github.com/bluesky-social/atproto/pull/4791) [`3aa18f8`](https://github.com/bluesky-social/atproto/commit/3aa18f84ef80a170b572076feeb649906ffcb06c) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Codegen updated lexicons + +- Updated dependencies [[`0dbea15`](https://github.com/bluesky-social/atproto/commit/0dbea15da48a6ca913cc3a3a2d8c0ffe64d7c69a), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`527f5d4`](https://github.com/bluesky-social/atproto/commit/527f5d4c5d0c9264c2ff6f23ad06a41163fc6809)]: + - @atproto/syntax@0.5.2 + +## 0.19.4 + +### Patch Changes + +- [#4709](https://github.com/bluesky-social/atproto/pull/4709) [`9f9f71a`](https://github.com/bluesky-social/atproto/commit/9f9f71a6a3e58ccbd5e6d3ee079b570096cb11fa) Thanks [@foysalit](https://github.com/foysalit)! - Introduce a purge event to remove ozone's data on age assurance + +- Updated dependencies [[`67eb0c1`](https://github.com/bluesky-social/atproto/commit/67eb0c19ac415e762e221b2ccda9f0bcf7b3dd6f)]: + - @atproto/syntax@0.5.1 + +## 0.19.3 + +### Patch Changes + +- [#4717](https://github.com/bluesky-social/atproto/pull/4717) [`76ab6ea`](https://github.com/bluesky-social/atproto/commit/76ab6eaa7bfa49fc218299d09446bb339c700bb5) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Trust `status` returned from `refreshSession` and do not fall back to a potentially stale value from `this.session`. + +## 0.19.2 + +### Patch Changes + +- [#4683](https://github.com/bluesky-social/atproto/pull/4683) [`6634140`](https://github.com/bluesky-social/atproto/commit/66341400d49d1210619b000a040852d87085c32c) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Introduce recIdStr field + +- [#4713](https://github.com/bluesky-social/atproto/pull/4713) [`0e5df95`](https://github.com/bluesky-social/atproto/commit/0e5df95e3a8d81931524848d301cd43d1f12fb78) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Make sure to always trigger a `refreshSession` when `resumeSession()` is called + +## 0.19.1 + +### Patch Changes + +- [#4704](https://github.com/bluesky-social/atproto/pull/4704) [`137065b`](https://github.com/bluesky-social/atproto/commit/137065b333b8c9b97e6b3b2ac6147c7509a1ae42) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Add feed to sendInteractions input + +- Updated dependencies [[`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`52834ab`](https://github.com/bluesky-social/atproto/commit/52834aba182da8df3611fd9dff924e6c6a3973a7), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd)]: + - @atproto/syntax@0.5.0 + - @atproto/common-web@0.4.18 + - @atproto/lexicon@0.6.2 + +## 0.19.0 + +### Minor Changes + +- [#4631](https://github.com/bluesky-social/atproto/pull/4631) [`450f085`](https://github.com/bluesky-social/atproto/commit/450f0856630fa08c20dc60fef8b5d2a07b9a2552) Thanks [@LotharieSlayer](https://github.com/LotharieSlayer)! - Updating atproto app password based session example in README + ## 0.18.21 ### Patch Changes diff --git a/packages/api/README.md b/packages/api/README.md index 41e7913d861..c630466e754 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -18,9 +18,11 @@ yarn add @atproto/api Then in your application: ```typescript -import { AtpAgent } from '@atproto/api' +import { Agent, CredentialSession } from '@atproto/api' -const agent = new AtpAgent({ service: 'https://example.com' }) +const session = new CredentialSession(new URL('https://bsky.social')) +await session.login(account) +const agent = new Agent(session) ``` ## Usage @@ -35,7 +37,7 @@ manage sessions: #### App password based session management -Username / password based authentication can be performed using the `AtpAgent` +Username / password based authentication can be performed using the `Agent` class. > [!CAUTION] @@ -45,34 +47,26 @@ class. > `@atproto/oauth-client-*` packages). ```typescript -import { AtpAgent, AtpSessionEvent, AtpSessionData } from '@atproto/api' - -// configure connection to the server, without account authentication -const agent = new AtpAgent({ - service: 'https://example.com', - persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => { - // store the session-data for reuse - }, -}) +import { Agent, CredentialSession, type AtpAgentLoginOpts } from '@atproto/api' -// Change the agent state to an authenticated state either by: - -// 1) creating a new account on the server. -await agent.createAccount({ - email: 'alice@mail.com', - password: 'hunter2', - handle: 'alice.example.com', - inviteCode: 'some-code-12345-abcde', -}) +// Configure connection to the server with authentification +const account: AtpAgentLoginOpts = { + identifier: 'your.bsky.social', + password: 'xxxx-xxxx-xxxx-xxxx', +} -// 2) if an existing session was securely stored previously, then reuse that to resume the session. -await agent.resumeSession(savedSessionData) +async function authenticate(account: AtpAgentLoginOpts): Promise { + const session = new CredentialSession(new URL('https://example.com')) + await session.login(account) + const agent = new Agent(session) + return agent +} -// 3) if no old session was available, create a new one by logging in with password (App Password) -await agent.login({ - identifier: 'alice@mail.com', - password: 'hunter2', -}) +;(async () => { + console.log('Authenticating...') + const agent = await authenticate(account) + console.log(`Authenticated as from: ${agent.sessionManager.did}`) +})() ``` #### OAuth based session management diff --git a/packages/api/package.json b/packages/api/package.json index 0844509248d..99433c7330a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/api", - "version": "0.18.21", + "version": "0.19.8", "license": "MIT", "description": "Client library for atproto and Bluesky", "keywords": [ @@ -17,7 +17,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "codegen": "node ./scripts/generate-code.mjs && lex gen-api --yes ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/tools/ozone/*/* ../../lexicons/com/germnetwork/*", + "codegen": "lex gen-api --yes ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/tools/ozone/*/* ../../lexicons/com/germnetwork/*", + "prebuild": "node ./scripts/generate-code.mjs && ([ -f ./src/client/index.ts ] || pnpm run codegen)", "build": "tsc --build tsconfig.build.json", "test": "jest" }, diff --git a/packages/api/scripts/code/labels.mjs b/packages/api/scripts/code/labels.mjs index 163589c1ffe..c2dae9086a5 100644 --- a/packages/api/scripts/code/labels.mjs +++ b/packages/api/scripts/code/labels.mjs @@ -1,22 +1,27 @@ -import * as url from 'url' -import { readFileSync, writeFileSync } from 'fs' -import { join } from 'path' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import * as prettier from 'prettier' +import labelsDef from '../../definitions/labels.json' with { type: 'json' } -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) +const __dirname = dirname(fileURLToPath(import.meta.url)) -const labelsDef = JSON.parse( - readFileSync( - join(__dirname, '..', '..', 'definitions', 'labels.json'), - 'utf8', - ), -) +export async function labels() { + const content = await gen() -writeFileSync( - join(__dirname, '..', '..', 'src', 'moderation', 'const', 'labels.ts'), - await gen(), - 'utf8', -) + const path = join( + __dirname, + '..', + '..', + 'src', + 'moderation', + 'const', + 'labels.ts', + ) + + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content, 'utf8') +} async function gen() { const knownValues = new Set() @@ -70,5 +75,3 @@ async function gen() { { semi: false, parser: 'typescript', singleQuote: true }, ) } - -export {} diff --git a/packages/api/scripts/generate-code.mjs b/packages/api/scripts/generate-code.mjs index bf182eb7774..595db9fb20c 100644 --- a/packages/api/scripts/generate-code.mjs +++ b/packages/api/scripts/generate-code.mjs @@ -1,3 +1,4 @@ -import './code/labels.mjs' +import { labels } from './code/labels.mjs' +export { labels } -export {} +labels() diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index 9879f3e0c9c..ae14fc7720f 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -1,6 +1,6 @@ import AwaitLock from 'await-lock' import { TID, retry } from '@atproto/common-web' -import { AtUri, ensureValidDid } from '@atproto/syntax' +import { AtUri, DidString, ensureValidDidRegex } from '@atproto/syntax' import { FetchHandler, FetchHandlerOptions, @@ -63,7 +63,7 @@ const THREAD_VIEW_PREF_DEFAULTS = { sort: 'hotness', } -export type { FetchHandler } +export type { DidString, FetchHandler } /** * An {@link Agent} is an {@link AtpBaseClient} with the following @@ -207,8 +207,11 @@ export class Agent extends XrpcClient { /** * Get the authenticated user's DID, if any. */ - get did() { - return this.sessionManager.did + get did(): DidString | undefined { + const { did } = this.sessionManager + if (!did) return undefined + ensureValidDidRegex(did) + return did } /** @deprecated Use {@link Agent.assertDid} instead */ @@ -219,7 +222,7 @@ export class Agent extends XrpcClient { /** * Get the authenticated user's DID, or throw an error if not authenticated. */ - get assertDid(): string { + get assertDid(): DidString { this.assertAuthenticated() return this.did } @@ -227,7 +230,7 @@ export class Agent extends XrpcClient { /** * Assert that the user is authenticated. */ - public assertAuthenticated(): asserts this is { did: string } { + public assertAuthenticated(): asserts this is { did: DidString } { if (!this.did) throw new Error('Not logged in') } @@ -890,7 +893,7 @@ export class Agent extends XrpcClient { labelerDid?: string, ) { if (labelerDid) { - ensureValidDid(labelerDid) + ensureValidDidRegex(labelerDid) } await this.updatePreferences((prefs) => { const labelPref = prefs diff --git a/packages/api/src/atp-agent.ts b/packages/api/src/atp-agent.ts index 2b3b6d8b764..05c2951daa5 100644 --- a/packages/api/src/atp-agent.ts +++ b/packages/api/src/atp-agent.ts @@ -88,10 +88,6 @@ export class AtpAgent extends Agent { return this.sessionManager.hasSession } - get did() { - return this.sessionManager.did - } - get serviceUrl() { return this.sessionManager.serviceUrl } @@ -159,7 +155,7 @@ export class AtpAgent extends Agent { export class CredentialSession implements SessionManager { public pdsUrl?: URL // The PDS URL, driven by the did doc public session?: AtpSessionData - public refreshSessionPromise: Promise | undefined + public refreshSessionPromise?: Promise /** * Private {@link ComAtprotoServerNS} used to perform session management API @@ -372,47 +368,28 @@ export class CredentialSession implements SessionManager { */ async resumeSession( session: AtpSessionData, - ): Promise< - | ComAtprotoServerGetSession.Response - | ComAtprotoServerRefreshSession.Response - > { + ): Promise { // Protect against multiple calls to resumeSession that would trigger a // refresh for the same session simultaneously. // Ideally, this check would be based on a session identifier, but since // we don't have one, we will just check the refresh token. - if (session.refreshJwt === this.session?.refreshJwt) { - // Protect against refreshes in progress - await this.refreshSessionPromise - - // Another concurrent operation may have replaced the session while we - // were waiting for the refresh to complete. - if (session.did !== this.session?.did) { - throw new Error('DID mismatch on resumeSession') - } - - return this.server.getSession(undefined, { - headers: { authorization: `Bearer ${this.session.accessJwt}` }, - }) + if (session.refreshJwt !== this.session?.refreshJwt) { + // Set the current session, and discard any pending refresh operation.. + this.session = session + this.refreshSessionPromise = undefined } - // Set the current session, then force a refresh, replacing any pending - // refresh operation. - this.session = session - this.refreshSessionPromise = undefined - - const promise = this._refreshSessionInner() + // Ensure that the session is still valid by forcing a refresh. This will + // also ensure that persistSession handler is called. + const result = await this.refreshSession() - // Discard any concurrent refresh, replacing it with this one. - this.refreshSessionPromise = promise - .then( - (): void => {}, - (): void => {}, - ) - .finally(() => { - this.refreshSessionPromise = undefined - }) + // Fool-proofing: another concurrent operation may have replaced the session + // while we were waiting for the refresh to complete. + if (session.did !== this.session?.did) { + throw new Error('DID mismatch on resumeSession') + } - return promise + return result } /** @@ -420,18 +397,23 @@ export class CredentialSession implements SessionManager { * - Wraps the actual implementation in a promise-guard to ensure only * one refresh is attempted at a time. */ - async refreshSession(): Promise { - if (!this.session) return + async refreshSession(): Promise { + if (!this.session) { + throw new Error('Unexpected state: no session to refresh') + } // Do not refresh if we already have a refresh in progress - return (this.refreshSessionPromise ||= this._refreshSessionInner() - .then( - (): void => {}, - (): void => {}, - ) - .finally(() => { + if (this.refreshSessionPromise) return this.refreshSessionPromise + + const promise = this._refreshSessionInner().finally(() => { + if (this.refreshSessionPromise === promise) { this.refreshSessionPromise = undefined - })) + } + }) + + this.refreshSessionPromise = promise + + return promise } /** @@ -494,7 +476,7 @@ export class CredentialSession implements SessionManager { emailConfirmed: data.emailConfirmed ?? session.emailConfirmed, emailAuthFactor: data.emailAuthFactor ?? session.emailAuthFactor, active: data.active ?? session.active ?? true, - status: data.status ?? session.status, + status: data.status, } this._updateApiEndpoint(res.data.didDoc) diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index c6597ba89ee..19e66883625 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -124,16 +124,22 @@ import * as AppBskyUnspeccedGetAgeAssuranceState from './types/app/bsky/unspecce import * as AppBskyUnspeccedGetConfig from './types/app/bsky/unspecced/getConfig.js' import * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacks from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.js' import * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacksSkeleton.js' +import * as AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.js' import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators.js' import * as AppBskyUnspeccedGetPostThreadOtherV2 from './types/app/bsky/unspecced/getPostThreadOtherV2.js' import * as AppBskyUnspeccedGetPostThreadV2 from './types/app/bsky/unspecced/getPostThreadV2.js' import * as AppBskyUnspeccedGetSuggestedFeeds from './types/app/bsky/unspecced/getSuggestedFeeds.js' import * as AppBskyUnspeccedGetSuggestedFeedsSkeleton from './types/app/bsky/unspecced/getSuggestedFeedsSkeleton.js' import * as AppBskyUnspeccedGetSuggestedOnboardingUsers from './types/app/bsky/unspecced/getSuggestedOnboardingUsers.js' -import * as AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton from './types/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.js' import * as AppBskyUnspeccedGetSuggestedStarterPacks from './types/app/bsky/unspecced/getSuggestedStarterPacks.js' import * as AppBskyUnspeccedGetSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getSuggestedStarterPacksSkeleton.js' import * as AppBskyUnspeccedGetSuggestedUsers from './types/app/bsky/unspecced/getSuggestedUsers.js' +import * as AppBskyUnspeccedGetSuggestedUsersForDiscover from './types/app/bsky/unspecced/getSuggestedUsersForDiscover.js' +import * as AppBskyUnspeccedGetSuggestedUsersForDiscoverSkeleton from './types/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.js' +import * as AppBskyUnspeccedGetSuggestedUsersForExplore from './types/app/bsky/unspecced/getSuggestedUsersForExplore.js' +import * as AppBskyUnspeccedGetSuggestedUsersForExploreSkeleton from './types/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.js' +import * as AppBskyUnspeccedGetSuggestedUsersForSeeMore from './types/app/bsky/unspecced/getSuggestedUsersForSeeMore.js' +import * as AppBskyUnspeccedGetSuggestedUsersForSeeMoreSkeleton from './types/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.js' import * as AppBskyUnspeccedGetSuggestedUsersSkeleton from './types/app/bsky/unspecced/getSuggestedUsersSkeleton.js' import * as AppBskyUnspeccedGetSuggestionsSkeleton from './types/app/bsky/unspecced/getSuggestionsSkeleton.js' import * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions.js' @@ -439,16 +445,22 @@ export * as AppBskyUnspeccedGetAgeAssuranceState from './types/app/bsky/unspecce export * as AppBskyUnspeccedGetConfig from './types/app/bsky/unspecced/getConfig.js' export * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacks from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.js' export * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacksSkeleton.js' +export * as AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.js' export * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators.js' export * as AppBskyUnspeccedGetPostThreadOtherV2 from './types/app/bsky/unspecced/getPostThreadOtherV2.js' export * as AppBskyUnspeccedGetPostThreadV2 from './types/app/bsky/unspecced/getPostThreadV2.js' export * as AppBskyUnspeccedGetSuggestedFeeds from './types/app/bsky/unspecced/getSuggestedFeeds.js' export * as AppBskyUnspeccedGetSuggestedFeedsSkeleton from './types/app/bsky/unspecced/getSuggestedFeedsSkeleton.js' export * as AppBskyUnspeccedGetSuggestedOnboardingUsers from './types/app/bsky/unspecced/getSuggestedOnboardingUsers.js' -export * as AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton from './types/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.js' export * as AppBskyUnspeccedGetSuggestedStarterPacks from './types/app/bsky/unspecced/getSuggestedStarterPacks.js' export * as AppBskyUnspeccedGetSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getSuggestedStarterPacksSkeleton.js' export * as AppBskyUnspeccedGetSuggestedUsers from './types/app/bsky/unspecced/getSuggestedUsers.js' +export * as AppBskyUnspeccedGetSuggestedUsersForDiscover from './types/app/bsky/unspecced/getSuggestedUsersForDiscover.js' +export * as AppBskyUnspeccedGetSuggestedUsersForDiscoverSkeleton from './types/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.js' +export * as AppBskyUnspeccedGetSuggestedUsersForExplore from './types/app/bsky/unspecced/getSuggestedUsersForExplore.js' +export * as AppBskyUnspeccedGetSuggestedUsersForExploreSkeleton from './types/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.js' +export * as AppBskyUnspeccedGetSuggestedUsersForSeeMore from './types/app/bsky/unspecced/getSuggestedUsersForSeeMore.js' +export * as AppBskyUnspeccedGetSuggestedUsersForSeeMoreSkeleton from './types/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.js' export * as AppBskyUnspeccedGetSuggestedUsersSkeleton from './types/app/bsky/unspecced/getSuggestedUsersSkeleton.js' export * as AppBskyUnspeccedGetSuggestionsSkeleton from './types/app/bsky/unspecced/getSuggestionsSkeleton.js' export * as AppBskyUnspeccedGetTaggedSuggestions from './types/app/bsky/unspecced/getTaggedSuggestions.js' @@ -3225,6 +3237,18 @@ export class AppBskyUnspeccedNS { ) } + getOnboardingSuggestedUsersSkeleton( + params?: AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.QueryParams, + opts?: AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.CallOptions, + ): Promise { + return this._client.call( + 'app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton', + params, + undefined, + opts, + ) + } + getPopularFeedGenerators( params?: AppBskyUnspeccedGetPopularFeedGenerators.QueryParams, opts?: AppBskyUnspeccedGetPopularFeedGenerators.CallOptions, @@ -3297,18 +3321,6 @@ export class AppBskyUnspeccedNS { ) } - getSuggestedOnboardingUsersSkeleton( - params?: AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.QueryParams, - opts?: AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.CallOptions, - ): Promise { - return this._client.call( - 'app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton', - params, - undefined, - opts, - ) - } - getSuggestedStarterPacks( params?: AppBskyUnspeccedGetSuggestedStarterPacks.QueryParams, opts?: AppBskyUnspeccedGetSuggestedStarterPacks.CallOptions, @@ -3345,6 +3357,78 @@ export class AppBskyUnspeccedNS { ) } + getSuggestedUsersForDiscover( + params?: AppBskyUnspeccedGetSuggestedUsersForDiscover.QueryParams, + opts?: AppBskyUnspeccedGetSuggestedUsersForDiscover.CallOptions, + ): Promise { + return this._client.call( + 'app.bsky.unspecced.getSuggestedUsersForDiscover', + params, + undefined, + opts, + ) + } + + getSuggestedUsersForDiscoverSkeleton( + params?: AppBskyUnspeccedGetSuggestedUsersForDiscoverSkeleton.QueryParams, + opts?: AppBskyUnspeccedGetSuggestedUsersForDiscoverSkeleton.CallOptions, + ): Promise { + return this._client.call( + 'app.bsky.unspecced.getSuggestedUsersForDiscoverSkeleton', + params, + undefined, + opts, + ) + } + + getSuggestedUsersForExplore( + params?: AppBskyUnspeccedGetSuggestedUsersForExplore.QueryParams, + opts?: AppBskyUnspeccedGetSuggestedUsersForExplore.CallOptions, + ): Promise { + return this._client.call( + 'app.bsky.unspecced.getSuggestedUsersForExplore', + params, + undefined, + opts, + ) + } + + getSuggestedUsersForExploreSkeleton( + params?: AppBskyUnspeccedGetSuggestedUsersForExploreSkeleton.QueryParams, + opts?: AppBskyUnspeccedGetSuggestedUsersForExploreSkeleton.CallOptions, + ): Promise { + return this._client.call( + 'app.bsky.unspecced.getSuggestedUsersForExploreSkeleton', + params, + undefined, + opts, + ) + } + + getSuggestedUsersForSeeMore( + params?: AppBskyUnspeccedGetSuggestedUsersForSeeMore.QueryParams, + opts?: AppBskyUnspeccedGetSuggestedUsersForSeeMore.CallOptions, + ): Promise { + return this._client.call( + 'app.bsky.unspecced.getSuggestedUsersForSeeMore', + params, + undefined, + opts, + ) + } + + getSuggestedUsersForSeeMoreSkeleton( + params?: AppBskyUnspeccedGetSuggestedUsersForSeeMoreSkeleton.QueryParams, + opts?: AppBskyUnspeccedGetSuggestedUsersForSeeMoreSkeleton.CallOptions, + ): Promise { + return this._client.call( + 'app.bsky.unspecced.getSuggestedUsersForSeeMoreSkeleton', + params, + undefined, + opts, + ) + } + getSuggestedUsersSkeleton( params?: AppBskyUnspeccedGetSuggestedUsersSkeleton.QueryParams, opts?: AppBskyUnspeccedGetSuggestedUsersSkeleton.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index e80cf13ef26..394c892ad63 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -871,6 +871,13 @@ export const schemaDict = { description: 'An optional embed associated with the status.', refs: ['lex:app.bsky.embed.external#view'], }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, expiresAt: { type: 'string', description: @@ -1028,6 +1035,10 @@ export const schemaDict = { }, recId: { type: 'integer', + description: 'DEPRECATED: use recIdStr instead.', + }, + recIdStr: { + type: 'string', description: 'Snowflake for this recommendation, use when submitting recommendation events.', }, @@ -2754,8 +2765,10 @@ export const schemaDict = { properties: { image: { type: 'blob', + description: + 'The raw image file. May be up to 2mb, formerly limited to 1mb.', accept: ['image/*'], - maxSize: 1000000, + maxSize: 2000000, }, alt: { type: 'string', @@ -4947,6 +4960,10 @@ export const schemaDict = { type: 'object', required: ['interactions'], properties: { + feed: { + type: 'string', + format: 'at-uri', + }, interactions: { type: 'array', items: { @@ -6235,16 +6252,20 @@ export const schemaDict = { ref: 'lex:app.bsky.actor.defs#profileView', }, }, + recIdStr: { + type: 'string', + description: + 'Snowflake for this recommendation, use when submitting recommendation events.', + }, isFallback: { type: 'boolean', description: - 'If true, response has fallen-back to generic results, and is not scoped using relativeToDid', + 'DEPRECATED, unused. Previously: if true, response has fallen-back to generic results, and is not scoped using relativeToDid', default: false, }, recId: { type: 'integer', - description: - 'Snowflake for this recommendation, use when submitting recommendation events.', + description: 'DEPRECATED: use recIdStr instead.', }, }, }, @@ -8056,6 +8077,63 @@ export const schemaDict = { }, }, }, + AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton', + defs: { + main: { + type: 'query', + description: + 'Get a skeleton of suggested users for onboarding. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedOnboardingUsers', + parameters: { + type: 'params', + properties: { + viewer: { + type: 'string', + format: 'did', + description: + 'DID of the account making the request (not included for public/unauthenticated queries).', + }, + category: { + type: 'string', + description: 'Category of users to get suggestions for.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 50, + default: 25, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['dids'], + properties: { + dids: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, + }, + recId: { + type: 'string', + description: 'DEPRECATED: use recIdStr instead.', + }, + recIdStr: { + type: 'string', + description: + 'Snowflake for this recommendation, use when submitting recommendation events.', + }, + }, + }, + }, + }, + }, + }, AppBskyUnspeccedGetPopularFeedGenerators: { lexicon: 1, id: 'app.bsky.unspecced.getPopularFeedGenerators', @@ -8380,6 +8458,10 @@ export const schemaDict = { }, }, recId: { + type: 'string', + description: 'DEPRECATED: use recIdStr instead.', + }, + recIdStr: { type: 'string', description: 'Snowflake for this recommendation, use when submitting recommendation events.', @@ -8390,14 +8472,51 @@ export const schemaDict = { }, }, }, - AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton: { + AppBskyUnspeccedGetSuggestedStarterPacks: { lexicon: 1, - id: 'app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton', + id: 'app.bsky.unspecced.getSuggestedStarterPacks', + defs: { + main: { + type: 'query', + description: 'Get a list of suggested starterpacks', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 25, + default: 10, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['starterPacks'], + properties: { + starterPacks: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#starterPackView', + }, + }, + }, + }, + }, + }, + }, + }, + AppBskyUnspeccedGetSuggestedStarterPacksSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestedStarterPacksSkeleton', defs: { main: { type: 'query', description: - 'Get a skeleton of suggested users for onboarding. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedOnboardingUsers', + 'Get a skeleton of suggested starterpacks. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedStarterpacks', parameters: { type: 'params', properties: { @@ -8407,6 +8526,43 @@ export const schemaDict = { description: 'DID of the account making the request (not included for public/unauthenticated queries).', }, + limit: { + type: 'integer', + minimum: 1, + maximum: 25, + default: 10, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['starterPacks'], + properties: { + starterPacks: { + type: 'array', + items: { + type: 'string', + format: 'at-uri', + }, + }, + }, + }, + }, + }, + }, + }, + AppBskyUnspeccedGetSuggestedUsers: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestedUsers', + defs: { + main: { + type: 'query', + description: 'Get a list of suggested users', + parameters: { + type: 'params', + properties: { category: { type: 'string', description: 'Category of users to get suggestions for.', @@ -8419,6 +8575,101 @@ export const schemaDict = { }, }, }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actors'], + properties: { + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, + }, + recId: { + type: 'string', + description: 'DEPRECATED: use recIdStr instead.', + }, + recIdStr: { + type: 'string', + description: + 'Snowflake for this recommendation, use when submitting recommendation events.', + }, + }, + }, + }, + }, + }, + }, + AppBskyUnspeccedGetSuggestedUsersForDiscover: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestedUsersForDiscover', + defs: { + main: { + type: 'query', + description: 'Get a list of suggested users for the Discover page', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 50, + default: 25, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['actors'], + properties: { + actors: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, + }, + recIdStr: { + type: 'string', + description: + 'Snowflake for this recommendation, use when submitting recommendation events.', + }, + }, + }, + }, + }, + }, + }, + AppBskyUnspeccedGetSuggestedUsersForDiscoverSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestedUsersForDiscoverSkeleton', + defs: { + main: { + type: 'query', + description: + 'Get a skeleton of suggested users for the Discover page. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedUsersForDiscover', + parameters: { + type: 'params', + properties: { + viewer: { + type: 'string', + format: 'did', + description: + 'DID of the account making the request (not included for public/unauthenticated queries).', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 50, + default: 25, + }, + }, + }, output: { encoding: 'application/json', schema: { @@ -8432,7 +8683,7 @@ export const schemaDict = { format: 'did', }, }, - recId: { + recIdStr: { type: 'string', description: 'Snowflake for this recommendation, use when submitting recommendation events.', @@ -8443,21 +8694,25 @@ export const schemaDict = { }, }, }, - AppBskyUnspeccedGetSuggestedStarterPacks: { + AppBskyUnspeccedGetSuggestedUsersForExplore: { lexicon: 1, - id: 'app.bsky.unspecced.getSuggestedStarterPacks', + id: 'app.bsky.unspecced.getSuggestedUsersForExplore', defs: { main: { type: 'query', - description: 'Get a list of suggested starterpacks', + description: 'Get a list of suggested users for the Explore page', parameters: { type: 'params', properties: { + category: { + type: 'string', + description: 'Category of users to get suggestions for.', + }, limit: { type: 'integer', minimum: 1, - maximum: 25, - default: 10, + maximum: 50, + default: 25, }, }, }, @@ -8465,29 +8720,34 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['starterPacks'], + required: ['actors'], properties: { - starterPacks: { + actors: { type: 'array', items: { type: 'ref', - ref: 'lex:app.bsky.graph.defs#starterPackView', + ref: 'lex:app.bsky.actor.defs#profileView', }, }, + recIdStr: { + type: 'string', + description: + 'Snowflake for this recommendation, use when submitting recommendation events.', + }, }, }, }, }, }, }, - AppBskyUnspeccedGetSuggestedStarterPacksSkeleton: { + AppBskyUnspeccedGetSuggestedUsersForExploreSkeleton: { lexicon: 1, - id: 'app.bsky.unspecced.getSuggestedStarterPacksSkeleton', + id: 'app.bsky.unspecced.getSuggestedUsersForExploreSkeleton', defs: { main: { type: 'query', description: - 'Get a skeleton of suggested starterpacks. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedStarterpacks', + 'Get a skeleton of suggested users for the Explore page. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedUsersForExplore', parameters: { type: 'params', properties: { @@ -8497,11 +8757,15 @@ export const schemaDict = { description: 'DID of the account making the request (not included for public/unauthenticated queries).', }, + category: { + type: 'string', + description: 'Category of users to get suggestions for.', + }, limit: { type: 'integer', minimum: 1, - maximum: 25, - default: 10, + maximum: 50, + default: 25, }, }, }, @@ -8509,28 +8773,33 @@ export const schemaDict = { encoding: 'application/json', schema: { type: 'object', - required: ['starterPacks'], + required: ['dids'], properties: { - starterPacks: { + dids: { type: 'array', items: { type: 'string', - format: 'at-uri', + format: 'did', }, }, + recIdStr: { + type: 'string', + description: + 'Snowflake for this recommendation, use when submitting recommendation events.', + }, }, }, }, }, }, }, - AppBskyUnspeccedGetSuggestedUsers: { + AppBskyUnspeccedGetSuggestedUsersForSeeMore: { lexicon: 1, - id: 'app.bsky.unspecced.getSuggestedUsers', + id: 'app.bsky.unspecced.getSuggestedUsersForSeeMore', defs: { main: { type: 'query', - description: 'Get a list of suggested users', + description: 'Get a list of suggested users for the See More page', parameters: { type: 'params', properties: { @@ -8559,7 +8828,60 @@ export const schemaDict = { ref: 'lex:app.bsky.actor.defs#profileView', }, }, - recId: { + recIdStr: { + type: 'string', + description: + 'Snowflake for this recommendation, use when submitting recommendation events.', + }, + }, + }, + }, + }, + }, + }, + AppBskyUnspeccedGetSuggestedUsersForSeeMoreSkeleton: { + lexicon: 1, + id: 'app.bsky.unspecced.getSuggestedUsersForSeeMoreSkeleton', + defs: { + main: { + type: 'query', + description: + 'Get a skeleton of suggested users for the See More page. Intended to be called and hydrated by app.bsky.unspecced.getSuggestedUsersForSeeMore', + parameters: { + type: 'params', + properties: { + viewer: { + type: 'string', + format: 'did', + description: + 'DID of the account making the request (not included for public/unauthenticated queries).', + }, + category: { + type: 'string', + description: 'Category of users to get suggestions for.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 50, + default: 25, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['dids'], + properties: { + dids: { + type: 'array', + items: { + type: 'string', + format: 'did', + }, + }, + recIdStr: { type: 'string', description: 'Snowflake for this recommendation, use when submitting recommendation events.', @@ -8613,6 +8935,10 @@ export const schemaDict = { }, }, recId: { + type: 'string', + description: 'DEPRECATED: use recIdStr instead.', + }, + recIdStr: { type: 'string', description: 'Snowflake for this recommendation, use when submitting recommendation events.', @@ -8681,6 +9007,10 @@ export const schemaDict = { }, recId: { type: 'integer', + description: 'DEPRECATED: use recIdStr instead.', + }, + recIdStr: { + type: 'string', description: 'Snowflake for this recommendation, use when submitting recommendation events.', }, @@ -11810,16 +12140,13 @@ export const schemaDict = { type: 'string', knownValues: [ '!hide', - '!no-promote', '!warn', '!no-unauthenticated', - 'dmca-violation', - 'doxxing', 'porn', 'sexual', 'nudity', - 'nsfl', - 'gore', + 'graphic-media', + 'bot', ], }, }, @@ -15986,6 +16313,7 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventPriorityScore', 'lex:tools.ozone.moderation.defs#ageAssuranceEvent', 'lex:tools.ozone.moderation.defs#ageAssuranceOverrideEvent', + 'lex:tools.ozone.moderation.defs#ageAssurancePurgeEvent', 'lex:tools.ozone.moderation.defs#revokeAccountCredentialsEvent', 'lex:tools.ozone.moderation.defs#scheduleTakedownEvent', 'lex:tools.ozone.moderation.defs#cancelScheduledTakedownEvent', @@ -16063,6 +16391,7 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventPriorityScore', 'lex:tools.ozone.moderation.defs#ageAssuranceEvent', 'lex:tools.ozone.moderation.defs#ageAssuranceOverrideEvent', + 'lex:tools.ozone.moderation.defs#ageAssurancePurgeEvent', 'lex:tools.ozone.moderation.defs#revokeAccountCredentialsEvent', 'lex:tools.ozone.moderation.defs#scheduleTakedownEvent', 'lex:tools.ozone.moderation.defs#cancelScheduledTakedownEvent', @@ -16629,6 +16958,19 @@ export const schemaDict = { }, }, }, + ageAssurancePurgeEvent: { + type: 'object', + description: + 'Purges all age assurance events for the subject. Only works on DID subjects. Moderator-only.', + required: ['comment'], + properties: { + comment: { + type: 'string', + minLength: 1, + description: 'Comment describing the reason for the purge.', + }, + }, + }, revokeAccountCredentialsEvent: { type: 'object', description: @@ -17502,6 +17844,7 @@ export const schemaDict = { 'lex:tools.ozone.moderation.defs#modEventPriorityScore', 'lex:tools.ozone.moderation.defs#ageAssuranceEvent', 'lex:tools.ozone.moderation.defs#ageAssuranceOverrideEvent', + 'lex:tools.ozone.moderation.defs#ageAssurancePurgeEvent', 'lex:tools.ozone.moderation.defs#revokeAccountCredentialsEvent', 'lex:tools.ozone.moderation.defs#scheduleTakedownEvent', 'lex:tools.ozone.moderation.defs#cancelScheduledTakedownEvent', @@ -20822,6 +21165,8 @@ export const ids = { 'app.bsky.unspecced.getOnboardingSuggestedStarterPacks', AppBskyUnspeccedGetOnboardingSuggestedStarterPacksSkeleton: 'app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton', + AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton: + 'app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton', AppBskyUnspeccedGetPopularFeedGenerators: 'app.bsky.unspecced.getPopularFeedGenerators', AppBskyUnspeccedGetPostThreadOtherV2: @@ -20832,13 +21177,23 @@ export const ids = { 'app.bsky.unspecced.getSuggestedFeedsSkeleton', AppBskyUnspeccedGetSuggestedOnboardingUsers: 'app.bsky.unspecced.getSuggestedOnboardingUsers', - AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton: - 'app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton', AppBskyUnspeccedGetSuggestedStarterPacks: 'app.bsky.unspecced.getSuggestedStarterPacks', AppBskyUnspeccedGetSuggestedStarterPacksSkeleton: 'app.bsky.unspecced.getSuggestedStarterPacksSkeleton', AppBskyUnspeccedGetSuggestedUsers: 'app.bsky.unspecced.getSuggestedUsers', + AppBskyUnspeccedGetSuggestedUsersForDiscover: + 'app.bsky.unspecced.getSuggestedUsersForDiscover', + AppBskyUnspeccedGetSuggestedUsersForDiscoverSkeleton: + 'app.bsky.unspecced.getSuggestedUsersForDiscoverSkeleton', + AppBskyUnspeccedGetSuggestedUsersForExplore: + 'app.bsky.unspecced.getSuggestedUsersForExplore', + AppBskyUnspeccedGetSuggestedUsersForExploreSkeleton: + 'app.bsky.unspecced.getSuggestedUsersForExploreSkeleton', + AppBskyUnspeccedGetSuggestedUsersForSeeMore: + 'app.bsky.unspecced.getSuggestedUsersForSeeMore', + AppBskyUnspeccedGetSuggestedUsersForSeeMoreSkeleton: + 'app.bsky.unspecced.getSuggestedUsersForSeeMoreSkeleton', AppBskyUnspeccedGetSuggestedUsersSkeleton: 'app.bsky.unspecced.getSuggestedUsersSkeleton', AppBskyUnspeccedGetSuggestionsSkeleton: 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 90a6c5ea972..f1741e9c667 100644 --- a/packages/api/src/client/types/app/bsky/actor/defs.ts +++ b/packages/api/src/client/types/app/bsky/actor/defs.ts @@ -695,6 +695,7 @@ export interface StatusView { status: 'app.bsky.actor.status#live' | (string & {}) record: { [_ in string]: unknown } embed?: $Typed | { $type: string } + labels?: ComAtprotoLabelDefs.Label[] /** The date when this status will expire. The application might choose to no longer return the status after expiration. */ expiresAt?: string /** True if the status is not expired, false if it is expired. Only present if expiration was set. */ diff --git a/packages/api/src/client/types/app/bsky/actor/getSuggestions.ts b/packages/api/src/client/types/app/bsky/actor/getSuggestions.ts index 7b1de9666ab..4f286106fec 100644 --- a/packages/api/src/client/types/app/bsky/actor/getSuggestions.ts +++ b/packages/api/src/client/types/app/bsky/actor/getSuggestions.ts @@ -25,8 +25,10 @@ export type InputSchema = undefined export interface OutputSchema { cursor?: string actors: AppBskyActorDefs.ProfileView[] - /** Snowflake for this recommendation, use when submitting recommendation events. */ + /** DEPRECATED: use recIdStr instead. */ recId?: number + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string } export interface CallOptions { diff --git a/packages/api/src/client/types/app/bsky/embed/images.ts b/packages/api/src/client/types/app/bsky/embed/images.ts index bb448002fd5..2f47090fd37 100644 --- a/packages/api/src/client/types/app/bsky/embed/images.ts +++ b/packages/api/src/client/types/app/bsky/embed/images.ts @@ -32,6 +32,7 @@ export function validateMain(v: V) { export interface Image { $type?: 'app.bsky.embed.images#image' + /** The raw image file. May be up to 2mb, formerly limited to 1mb. */ image: BlobRef /** Alt text description of the image, for accessibility. */ alt: string diff --git a/packages/api/src/client/types/app/bsky/feed/sendInteractions.ts b/packages/api/src/client/types/app/bsky/feed/sendInteractions.ts index dc664f5f7c2..720f5aa1f6b 100644 --- a/packages/api/src/client/types/app/bsky/feed/sendInteractions.ts +++ b/packages/api/src/client/types/app/bsky/feed/sendInteractions.ts @@ -19,6 +19,7 @@ const id = 'app.bsky.feed.sendInteractions' export type QueryParams = {} export interface InputSchema { + feed?: string interactions: AppBskyFeedDefs.Interaction[] } diff --git a/packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts index 06d0dba7a0b..5eb8eca9188 100644 --- a/packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -23,9 +23,11 @@ export type InputSchema = undefined export interface OutputSchema { suggestions: AppBskyActorDefs.ProfileView[] - /** If true, response has fallen-back to generic results, and is not scoped using relativeToDid */ - isFallback: boolean /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string + /** DEPRECATED, unused. Previously: if true, response has fallen-back to generic results, and is not scoped using relativeToDid */ + isFallback: boolean + /** DEPRECATED: use recIdStr instead. */ recId?: number } diff --git a/packages/api/src/client/types/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.ts b/packages/api/src/client/types/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.ts new file mode 100644 index 00000000000..c2a5bf8e722 --- /dev/null +++ b/packages/api/src/client/types/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { HeadersMap, XRPCError } from '@atproto/xrpc' +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton' + +export type QueryParams = { + /** DID of the account making the request (not included for public/unauthenticated queries). */ + viewer?: string + /** Category of users to get suggestions for. */ + category?: string + limit?: number +} +export type InputSchema = undefined + +export interface OutputSchema { + dids: string[] + /** DEPRECATED: use recIdStr instead. */ + recId?: string + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string +} + +export interface CallOptions { + signal?: AbortSignal + headers?: HeadersMap +} + +export interface Response { + success: boolean + headers: HeadersMap + data: OutputSchema +} + +export function toKnownErr(e: any) { + return e +} diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedOnboardingUsers.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedOnboardingUsers.ts index 1c266533fe2..a3f64b0ef9f 100644 --- a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedOnboardingUsers.ts +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedOnboardingUsers.ts @@ -25,8 +25,10 @@ export type InputSchema = undefined export interface OutputSchema { actors: AppBskyActorDefs.ProfileView[] - /** Snowflake for this recommendation, use when submitting recommendation events. */ + /** DEPRECATED: use recIdStr instead. */ recId?: string + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string } export interface CallOptions { diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsers.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsers.ts index b7369e5112b..1250df94d95 100644 --- a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsers.ts +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsers.ts @@ -25,8 +25,10 @@ export type InputSchema = undefined export interface OutputSchema { actors: AppBskyActorDefs.ProfileView[] - /** Snowflake for this recommendation, use when submitting recommendation events. */ + /** DEPRECATED: use recIdStr instead. */ recId?: string + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string } export interface CallOptions { diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForDiscover.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForDiscover.ts new file mode 100644 index 00000000000..468bd7f69fd --- /dev/null +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForDiscover.ts @@ -0,0 +1,43 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { HeadersMap, XRPCError } from '@atproto/xrpc' +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' +import type * as AppBskyActorDefs from '../actor/defs.js' + +const is$typed = _is$typed, + validate = _validate +const id = 'app.bsky.unspecced.getSuggestedUsersForDiscover' + +export type QueryParams = { + limit?: number +} +export type InputSchema = undefined + +export interface OutputSchema { + actors: AppBskyActorDefs.ProfileView[] + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string +} + +export interface CallOptions { + signal?: AbortSignal + headers?: HeadersMap +} + +export interface Response { + success: boolean + headers: HeadersMap + data: OutputSchema +} + +export function toKnownErr(e: any) { + return e +} diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.ts new file mode 100644 index 00000000000..a846ce1c5ab --- /dev/null +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForDiscoverSkeleton.ts @@ -0,0 +1,44 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { HeadersMap, XRPCError } from '@atproto/xrpc' +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'app.bsky.unspecced.getSuggestedUsersForDiscoverSkeleton' + +export type QueryParams = { + /** DID of the account making the request (not included for public/unauthenticated queries). */ + viewer?: string + limit?: number +} +export type InputSchema = undefined + +export interface OutputSchema { + dids: string[] + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string +} + +export interface CallOptions { + signal?: AbortSignal + headers?: HeadersMap +} + +export interface Response { + success: boolean + headers: HeadersMap + data: OutputSchema +} + +export function toKnownErr(e: any) { + return e +} diff --git a/packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestedOnboardingUsers.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForExplore.ts similarity index 65% rename from packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestedOnboardingUsers.ts rename to packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForExplore.ts index 904242dc4d6..82f40896ad7 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestedOnboardingUsers.ts +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForExplore.ts @@ -1,6 +1,7 @@ /** * GENERATED CODE - DO NOT MODIFY */ +import { HeadersMap, XRPCError } from '@atproto/xrpc' import { type ValidationResult, BlobRef } from '@atproto/lexicon' import { CID } from 'multiformats/cid' import { validate as _validate } from '../../../../lexicons' @@ -13,32 +14,32 @@ import type * as AppBskyActorDefs from '../actor/defs.js' const is$typed = _is$typed, validate = _validate -const id = 'app.bsky.unspecced.getSuggestedOnboardingUsers' +const id = 'app.bsky.unspecced.getSuggestedUsersForExplore' export type QueryParams = { /** Category of users to get suggestions for. */ category?: string - limit: number + limit?: number } export type InputSchema = undefined export interface OutputSchema { actors: AppBskyActorDefs.ProfileView[] /** Snowflake for this recommendation, use when submitting recommendation events. */ - recId?: string + recIdStr?: string } -export type HandlerInput = void - -export interface HandlerSuccess { - encoding: 'application/json' - body: OutputSchema - headers?: { [key: string]: string } +export interface CallOptions { + signal?: AbortSignal + headers?: HeadersMap } -export interface HandlerError { - status: number - message?: string +export interface Response { + success: boolean + headers: HeadersMap + data: OutputSchema } -export type HandlerOutput = HandlerError | HandlerSuccess +export function toKnownErr(e: any) { + return e +} diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.ts similarity index 92% rename from packages/api/src/client/types/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.ts rename to packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.ts index 4c0fd90d2df..9cbed886eed 100644 --- a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.ts +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForExploreSkeleton.ts @@ -13,7 +13,7 @@ import { const is$typed = _is$typed, validate = _validate -const id = 'app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton' +const id = 'app.bsky.unspecced.getSuggestedUsersForExploreSkeleton' export type QueryParams = { /** DID of the account making the request (not included for public/unauthenticated queries). */ @@ -27,7 +27,7 @@ export type InputSchema = undefined export interface OutputSchema { dids: string[] /** Snowflake for this recommendation, use when submitting recommendation events. */ - recId?: string + recIdStr?: string } export interface CallOptions { diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForSeeMore.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForSeeMore.ts new file mode 100644 index 00000000000..fdd7c854433 --- /dev/null +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForSeeMore.ts @@ -0,0 +1,45 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { HeadersMap, XRPCError } from '@atproto/xrpc' +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' +import type * as AppBskyActorDefs from '../actor/defs.js' + +const is$typed = _is$typed, + validate = _validate +const id = 'app.bsky.unspecced.getSuggestedUsersForSeeMore' + +export type QueryParams = { + /** Category of users to get suggestions for. */ + category?: string + limit?: number +} +export type InputSchema = undefined + +export interface OutputSchema { + actors: AppBskyActorDefs.ProfileView[] + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string +} + +export interface CallOptions { + signal?: AbortSignal + headers?: HeadersMap +} + +export interface Response { + success: boolean + headers: HeadersMap + data: OutputSchema +} + +export function toKnownErr(e: any) { + return e +} diff --git a/packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestedUsersSkeleton.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.ts similarity index 66% rename from packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestedUsersSkeleton.ts rename to packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.ts index 78dc0c9f185..43f9d7c399b 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/unspecced/getSuggestedUsersSkeleton.ts +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersForSeeMoreSkeleton.ts @@ -1,6 +1,7 @@ /** * GENERATED CODE - DO NOT MODIFY */ +import { HeadersMap, XRPCError } from '@atproto/xrpc' import { type ValidationResult, BlobRef } from '@atproto/lexicon' import { CID } from 'multiformats/cid' import { validate as _validate } from '../../../../lexicons' @@ -12,34 +13,34 @@ import { const is$typed = _is$typed, validate = _validate -const id = 'app.bsky.unspecced.getSuggestedUsersSkeleton' +const id = 'app.bsky.unspecced.getSuggestedUsersForSeeMoreSkeleton' export type QueryParams = { /** DID of the account making the request (not included for public/unauthenticated queries). */ viewer?: string /** Category of users to get suggestions for. */ category?: string - limit: number + limit?: number } export type InputSchema = undefined export interface OutputSchema { dids: string[] /** Snowflake for this recommendation, use when submitting recommendation events. */ - recId?: string + recIdStr?: string } -export type HandlerInput = void - -export interface HandlerSuccess { - encoding: 'application/json' - body: OutputSchema - headers?: { [key: string]: string } +export interface CallOptions { + signal?: AbortSignal + headers?: HeadersMap } -export interface HandlerError { - status: number - message?: string +export interface Response { + success: boolean + headers: HeadersMap + data: OutputSchema } -export type HandlerOutput = HandlerError | HandlerSuccess +export function toKnownErr(e: any) { + return e +} diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersSkeleton.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersSkeleton.ts index f40be7a5626..9cbff2bf8fd 100644 --- a/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersSkeleton.ts +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestedUsersSkeleton.ts @@ -26,8 +26,10 @@ export type InputSchema = undefined export interface OutputSchema { dids: string[] - /** Snowflake for this recommendation, use when submitting recommendation events. */ + /** DEPRECATED: use recIdStr instead. */ recId?: string + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string } export interface CallOptions { diff --git a/packages/api/src/client/types/app/bsky/unspecced/getSuggestionsSkeleton.ts b/packages/api/src/client/types/app/bsky/unspecced/getSuggestionsSkeleton.ts index f306b8e27d9..4fd9e041d31 100644 --- a/packages/api/src/client/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +++ b/packages/api/src/client/types/app/bsky/unspecced/getSuggestionsSkeleton.ts @@ -31,8 +31,10 @@ export interface OutputSchema { actors: AppBskyUnspeccedDefs.SkeletonSearchActor[] /** DID of the account these suggestions are relative to. If this is returned undefined, suggestions are based on the viewer. */ relativeToDid?: string - /** Snowflake for this recommendation, use when submitting recommendation events. */ + /** DEPRECATED: use recIdStr instead. */ recId?: number + /** Snowflake for this recommendation, use when submitting recommendation events. */ + recIdStr?: string } export interface CallOptions { diff --git a/packages/api/src/client/types/com/atproto/label/defs.ts b/packages/api/src/client/types/com/atproto/label/defs.ts index 3f2d846af84..c9b0fb813c6 100644 --- a/packages/api/src/client/types/com/atproto/label/defs.ts +++ b/packages/api/src/client/types/com/atproto/label/defs.ts @@ -133,14 +133,11 @@ export function validateLabelValueDefinitionStrings(v: V) { export type LabelValue = | '!hide' - | '!no-promote' | '!warn' | '!no-unauthenticated' - | 'dmca-violation' - | 'doxxing' | 'porn' | 'sexual' | 'nudity' - | 'nsfl' - | 'gore' + | 'graphic-media' + | 'bot' | (string & {}) diff --git a/packages/api/src/client/types/tools/ozone/moderation/defs.ts b/packages/api/src/client/types/tools/ozone/moderation/defs.ts index 7e89308f443..ca25960a615 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/defs.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/defs.ts @@ -46,6 +46,7 @@ export interface ModEventView { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -98,6 +99,7 @@ export interface ModEventViewDetail { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -503,6 +505,23 @@ export function validateAgeAssuranceOverrideEvent(v: V) { ) } +/** Purges all age assurance events for the subject. Only works on DID subjects. Moderator-only. */ +export interface AgeAssurancePurgeEvent { + $type?: 'tools.ozone.moderation.defs#ageAssurancePurgeEvent' + /** Comment describing the reason for the purge. */ + comment: string +} + +const hashAgeAssurancePurgeEvent = 'ageAssurancePurgeEvent' + +export function isAgeAssurancePurgeEvent(v: V) { + return is$typed(v, id, hashAgeAssurancePurgeEvent) +} + +export function validateAgeAssurancePurgeEvent(v: V) { + return validate(v, id, hashAgeAssurancePurgeEvent) +} + /** Account credentials revocation by moderators. Only works on DID subjects. */ export interface RevokeAccountCredentialsEvent { $type?: 'tools.ozone.moderation.defs#revokeAccountCredentialsEvent' diff --git a/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts b/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts index a01baad300b..43ae18e1975 100644 --- a/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts +++ b/packages/api/src/client/types/tools/ozone/moderation/emitEvent.ts @@ -43,6 +43,7 @@ export interface InputSchema { | $Typed | $Typed | $Typed + | $Typed | $Typed | $Typed | $Typed diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index afe1a7eae84..ef8e0f979ce 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -10,11 +10,13 @@ export { stringifyLex, } from '@atproto/lexicon' export { parseLanguage } from '@atproto/common-web' +export { XRPCError } from '@atproto/xrpc' + export * from './types' export * from './const' export * from './util' export * from './client' -export { schemas } from './client/lexicons' +export { ids, schemas } from './client/lexicons' export type { $Typed, Un$Typed } from './client/util' export { asPredicate } from './client/util' export * from './rich-text/rich-text' diff --git a/packages/api/src/moderation/const/labels.ts b/packages/api/src/moderation/const/labels.ts deleted file mode 100644 index 3ec3350cae3..00000000000 --- a/packages/api/src/moderation/const/labels.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** this doc is generated by ./scripts/code/labels.mjs **/ -import { InterpretedLabelValueDefinition, LabelPreference } from '../types' - -export type KnownLabelValue = - | '!hide' - | '!warn' - | '!no-unauthenticated' - | 'porn' - | 'sexual' - | 'nudity' - | 'graphic-media' - | 'gore' - -export const DEFAULT_LABEL_SETTINGS: Record = { - porn: 'hide', - sexual: 'warn', - nudity: 'ignore', - 'graphic-media': 'warn', -} - -export const LABELS: Record = - { - '!hide': { - identifier: '!hide', - configurable: false, - defaultSetting: 'hide', - flags: ['no-override', 'no-self'], - severity: 'alert', - blurs: 'content', - behaviors: { - account: { - profileList: 'blur', - profileView: 'blur', - avatar: 'blur', - banner: 'blur', - displayName: 'blur', - contentList: 'blur', - contentView: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - displayName: 'blur', - }, - content: { - contentList: 'blur', - contentView: 'blur', - }, - }, - locales: [], - }, - '!warn': { - identifier: '!warn', - configurable: false, - defaultSetting: 'warn', - flags: ['no-self'], - severity: 'none', - blurs: 'content', - behaviors: { - account: { - profileList: 'blur', - profileView: 'blur', - avatar: 'blur', - banner: 'blur', - contentList: 'blur', - contentView: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - displayName: 'blur', - }, - content: { - contentList: 'blur', - contentView: 'blur', - }, - }, - locales: [], - }, - '!no-unauthenticated': { - identifier: '!no-unauthenticated', - configurable: false, - defaultSetting: 'hide', - flags: ['no-override', 'unauthed'], - severity: 'none', - blurs: 'content', - behaviors: { - account: { - profileList: 'blur', - profileView: 'blur', - avatar: 'blur', - banner: 'blur', - displayName: 'blur', - contentList: 'blur', - contentView: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - displayName: 'blur', - }, - content: { - contentList: 'blur', - contentView: 'blur', - }, - }, - locales: [], - }, - porn: { - identifier: 'porn', - configurable: true, - defaultSetting: 'hide', - flags: ['adult'], - severity: 'none', - blurs: 'media', - behaviors: { - account: { - avatar: 'blur', - banner: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - }, - content: { - contentMedia: 'blur', - }, - }, - locales: [], - }, - sexual: { - identifier: 'sexual', - configurable: true, - defaultSetting: 'warn', - flags: ['adult'], - severity: 'none', - blurs: 'media', - behaviors: { - account: { - avatar: 'blur', - banner: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - }, - content: { - contentMedia: 'blur', - }, - }, - locales: [], - }, - nudity: { - identifier: 'nudity', - configurable: true, - defaultSetting: 'ignore', - flags: [], - severity: 'none', - blurs: 'media', - behaviors: { - account: { - avatar: 'blur', - banner: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - }, - content: { - contentMedia: 'blur', - }, - }, - locales: [], - }, - 'graphic-media': { - identifier: 'graphic-media', - flags: ['adult'], - configurable: true, - defaultSetting: 'warn', - severity: 'none', - blurs: 'media', - behaviors: { - account: { - avatar: 'blur', - banner: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - }, - content: { - contentMedia: 'blur', - }, - }, - locales: [], - }, - /** @deprecated alias for `graphic-media` */ - gore: { - identifier: 'gore', - flags: ['adult'], - configurable: true, - defaultSetting: 'warn', - severity: 'none', - blurs: 'media', - behaviors: { - account: { - avatar: 'blur', - banner: 'blur', - }, - profile: { - avatar: 'blur', - banner: 'blur', - }, - content: { - contentMedia: 'blur', - }, - }, - locales: [], - }, - } diff --git a/packages/api/src/moderation/index.ts b/packages/api/src/moderation/index.ts index 1a8bdfb161a..153a1db350d 100644 --- a/packages/api/src/moderation/index.ts +++ b/packages/api/src/moderation/index.ts @@ -4,6 +4,7 @@ import { decideFeedGenerator } from './subjects/feed-generator' import { decideNotification } from './subjects/notification' import { decidePost } from './subjects/post' import { decideProfile } from './subjects/profile' +import { decideStatus } from './subjects/status' import { decideUserList } from './subjects/user-list' import { ModerationOpts, @@ -59,3 +60,10 @@ export function moderateUserList( ): ModerationDecision { return decideUserList(subject, opts) } + +export function moderateStatus( + subject: ModerationSubjectProfile, + opts: ModerationOpts, +): ModerationDecision { + return decideStatus(subject, opts) +} diff --git a/packages/api/src/moderation/subjects/status.ts b/packages/api/src/moderation/subjects/status.ts new file mode 100644 index 00000000000..11f3dc0ed0b --- /dev/null +++ b/packages/api/src/moderation/subjects/status.ts @@ -0,0 +1,27 @@ +import { ModerationDecision } from '../decision' +import { ModerationOpts, ModerationSubjectProfile } from '../types' +import { decideAccount } from './account' +import { decideProfile } from './profile' + +export function decideStatus( + subject: ModerationSubjectProfile, + opts: ModerationOpts, +): ModerationDecision { + const acc = new ModerationDecision() + + acc.setDid(subject.did) + acc.setIsMe(subject.did === opts.userDid) + if ('status' in subject) { + if (subject.status?.labels?.length) { + for (const label of subject.status.labels) { + acc.addLabel('content', label, opts) + } + } + } + + return ModerationDecision.merge( + acc, + decideAccount(subject, opts), + decideProfile(subject, opts), + ) +} diff --git a/packages/api/tests/errors.test.ts b/packages/api/tests/errors.test.ts index e2cfff26875..ff63e4e958b 100644 --- a/packages/api/tests/errors.test.ts +++ b/packages/api/tests/errors.test.ts @@ -9,7 +9,7 @@ describe('errors', () => { network = await TestNetworkNoAppView.create({ dbPostgresSchema: 'known_errors', }) - client = network.pds.getClient() + client = network.pds.getAgent() }) afterAll(async () => { diff --git a/packages/api/tests/moderation-prefs.test.ts b/packages/api/tests/moderation-prefs.test.ts index 0edaa913d7e..e82b725b0c2 100644 --- a/packages/api/tests/moderation-prefs.test.ts +++ b/packages/api/tests/moderation-prefs.test.ts @@ -17,7 +17,7 @@ describe('agent', () => { }) it('migrates legacy content-label prefs (no mutations)', async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user1.test', @@ -101,7 +101,7 @@ describe('agent', () => { }) it('adds/removes moderation services', async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user5.test', @@ -207,7 +207,7 @@ describe('agent', () => { }) it('sets label preferences globally and per-moderator', async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user7.test', @@ -273,7 +273,7 @@ describe('agent', () => { }) it(`updates label pref`, async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user8.test', @@ -297,7 +297,7 @@ describe('agent', () => { }) it(`double-write for legacy: 'graphic-media' in sync with 'gore'`, async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user9.test', @@ -319,7 +319,7 @@ describe('agent', () => { }) it(`double-write for legacy: 'porn' in sync with 'nsfw'`, async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user10.test', @@ -341,7 +341,7 @@ describe('agent', () => { }) it(`double-write for legacy: 'sexual' in sync with 'suggestive'`, async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user11.test', @@ -363,7 +363,7 @@ describe('agent', () => { }) it(`double-write for legacy: filters out existing old label pref if double-written`, async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user12.test', @@ -382,7 +382,7 @@ describe('agent', () => { }) it(`remaps old values to new on read`, async () => { - const agent = network.pds.getClient() + const agent = network.pds.getAgent() await agent.createAccount({ handle: 'user13.test', diff --git a/packages/aws/CHANGELOG.md b/packages/aws/CHANGELOG.md index e8192063cc2..fccc2270dcf 100644 --- a/packages/aws/CHANGELOG.md +++ b/packages/aws/CHANGELOG.md @@ -1,5 +1,12 @@ # @atproto/aws +## 0.2.32 + +### Patch Changes + +- Updated dependencies [[`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c)]: + - @atproto/repo@0.9.0 + ## 0.2.31 ### Patch Changes diff --git a/packages/aws/package.json b/packages/aws/package.json index e5f56c30f83..67634ef5bba 100644 --- a/packages/aws/package.json +++ b/packages/aws/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/aws", - "version": "0.2.31", + "version": "0.2.32", "license": "MIT", "description": "Shared AWS cloud API helpers for atproto services", "keywords": [ diff --git a/packages/bsky/.gitignore b/packages/bsky/.gitignore new file mode 100644 index 00000000000..a33cfc736a3 --- /dev/null +++ b/packages/bsky/.gitignore @@ -0,0 +1,4 @@ +# @atproto/lex-cli +src/lexicon +# @atproto/lex +src/lexicons diff --git a/packages/bsky/CHANGELOG.md b/packages/bsky/CHANGELOG.md index 32b38445e9e..5f878a289cb 100644 --- a/packages/bsky/CHANGELOG.md +++ b/packages/bsky/CHANGELOG.md @@ -1,5 +1,155 @@ # @atproto/bsky +## 0.0.226 + +### Patch Changes + +- [#4852](https://github.com/bluesky-social/atproto/pull/4852) [`b704c52`](https://github.com/bluesky-social/atproto/commit/b704c523270e82075e2ed4f38ee54d84a9baed88) Thanks [@jcalabro](https://github.com/jcalabro)! - Add interceptor to dataplane GRPC client + +- [#4849](https://github.com/bluesky-social/atproto/pull/4849) [`7c61f19`](https://github.com/bluesky-social/atproto/commit/7c61f196c374a8a0d54c055722c501467ffaca4f) Thanks [@rafaeleyng](https://github.com/rafaeleyng)! - Refactor hydrationCtx type + +- Updated dependencies [[`26d793a`](https://github.com/bluesky-social/atproto/commit/26d793af95a6fb3a50f9b2a97187d8ac4fecf676), [`26d793a`](https://github.com/bluesky-social/atproto/commit/26d793af95a6fb3a50f9b2a97187d8ac4fecf676), [`26d793a`](https://github.com/bluesky-social/atproto/commit/26d793af95a6fb3a50f9b2a97187d8ac4fecf676), [`55d06de`](https://github.com/bluesky-social/atproto/commit/55d06de80a1506908a04ed5c0834986cb5783797), [`26d793a`](https://github.com/bluesky-social/atproto/commit/26d793af95a6fb3a50f9b2a97187d8ac4fecf676)]: + - @atproto/syntax@0.5.4 + - @atproto/lex@0.0.25 + - @atproto/xrpc-server@0.10.20 + - @atproto/sync@0.2.2 + +## 0.0.225 + +### Patch Changes + +- [#4555](https://github.com/bluesky-social/atproto/pull/4555) [`aa1763d`](https://github.com/bluesky-social/atproto/commit/aa1763df0f1bb46014ba6a416646a08c61d97950) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Hydrate mod labels on actor `status` record views + +- [#4837](https://github.com/bluesky-social/atproto/pull/4837) [`7000746`](https://github.com/bluesky-social/atproto/commit/700074694157718e33c974b666f4563f9647a50c) Thanks [@devinivy](https://github.com/devinivy)! - Permit appview's mod service to see through blocks. + +- [#4835](https://github.com/bluesky-social/atproto/pull/4835) [`f6f100c`](https://github.com/bluesky-social/atproto/commit/f6f100c33700a7ff58a1458109cc7420131feed0) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Use "parse" mode when validating chat and notification declarations, as well as labels, lexicon data. + +- [#4823](https://github.com/bluesky-social/atproto/pull/4823) [`d8801e2`](https://github.com/bluesky-social/atproto/commit/d8801e2a17fe7062b7aa674475b384ead7518a17) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Increase max image upload size to 2 MB from 1 MB + +- Updated dependencies [[`aa1763d`](https://github.com/bluesky-social/atproto/commit/aa1763df0f1bb46014ba6a416646a08c61d97950), [`c62651d`](https://github.com/bluesky-social/atproto/commit/c62651dd69f1e18bd854b66e499b91fee9eaa856), [`d8801e2`](https://github.com/bluesky-social/atproto/commit/d8801e2a17fe7062b7aa674475b384ead7518a17), [`aa1763d`](https://github.com/bluesky-social/atproto/commit/aa1763df0f1bb46014ba6a416646a08c61d97950)]: + - @atproto/api@0.19.7 + - @atproto/lex@0.0.24 + - @atproto/common@0.5.16 + - @atproto/repo@0.9.1 + - @atproto/xrpc-server@0.10.19 + - @atproto/sync@0.2.1 + +## 0.0.224 + +### Patch Changes + +- [#4830](https://github.com/bluesky-social/atproto/pull/4830) [`42232c7`](https://github.com/bluesky-social/atproto/commit/42232c7f3622f1d4a4ebe78eb1321817a8df1c96) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Register new suggested user endpoints with the router + +## 0.0.223 + +### Patch Changes + +- [#4809](https://github.com/bluesky-social/atproto/pull/4809) [`2a5e2c2`](https://github.com/bluesky-social/atproto/commit/2a5e2c267f130df8eed87c9bdcb26e97841abc13) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Create new endpoints for suggested users + +- [#4810](https://github.com/bluesky-social/atproto/pull/4810) [`34f9ae1`](https://github.com/bluesky-social/atproto/commit/34f9ae1f6824bdd7f66c94c57e41cec757e267df) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Show suggestions to logged out users + +- Updated dependencies [[`2a5e2c2`](https://github.com/bluesky-social/atproto/commit/2a5e2c267f130df8eed87c9bdcb26e97841abc13), [`3711454`](https://github.com/bluesky-social/atproto/commit/371145432178b6c8c411f1289c266314cc7ec592)]: + - @atproto/api@0.19.6 + - @atproto/syntax@0.5.3 + +## 0.0.222 + +### Patch Changes + +- [#4793](https://github.com/bluesky-social/atproto/pull/4793) [`1479c87`](https://github.com/bluesky-social/atproto/commit/1479c87c6bf352694fdf298083e4f95287700c8e) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Temporarily allow use of legacy `[]` syntaxt for array params + +- [#4408](https://github.com/bluesky-social/atproto/pull/4408) [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add "codegen" as part of the build + +- [#4756](https://github.com/bluesky-social/atproto/pull/4756) [`db795be`](https://github.com/bluesky-social/atproto/commit/db795be786ba613aa9d244cdf27a557797c7669c) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Refactor `FeatureGatesClient` to add `scope()` method and docs. + +- [#4775](https://github.com/bluesky-social/atproto/pull/4775) [`b86dbfc`](https://github.com/bluesky-social/atproto/commit/b86dbfcbe32b80984f7049fcc3ddc3086b6feb2a) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Remove feature gate for new user onboarding + +- [#4780](https://github.com/bluesky-social/atproto/pull/4780) [`37b2f49`](https://github.com/bluesky-social/atproto/commit/37b2f492bb5c8c20b98e241d4a2e6e47052a9dab) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Ensure scoped `checkGates` merges outer context + +- [#4796](https://github.com/bluesky-social/atproto/pull/4796) [`ac6bd18`](https://github.com/bluesky-social/atproto/commit/ac6bd18f1dc3397dd29008eff2a1e40702a4e138) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Disable strict parsing of records + +- [#4790](https://github.com/bluesky-social/atproto/pull/4790) [`ae22362`](https://github.com/bluesky-social/atproto/commit/ae22362c595b94f3f491d344c0a3346403395eb2) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Add temp A/A test to getPostThreadV2 endpoint + +- [#4804](https://github.com/bluesky-social/atproto/pull/4804) [`4f11fc3`](https://github.com/bluesky-social/atproto/commit/4f11fc341a770e907b0e84ec267aafafeec5ff88) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Set loose query param parsing for `app.bsky.feed.getFeedGenerators` + +- Updated dependencies [[`0dbea15`](https://github.com/bluesky-social/atproto/commit/0dbea15da48a6ca913cc3a3a2d8c0ffe64d7c69a), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`4f11fc3`](https://github.com/bluesky-social/atproto/commit/4f11fc341a770e907b0e84ec267aafafeec5ff88), [`527f5d4`](https://github.com/bluesky-social/atproto/commit/527f5d4c5d0c9264c2ff6f23ad06a41163fc6809), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`1479c87`](https://github.com/bluesky-social/atproto/commit/1479c87c6bf352694fdf298083e4f95287700c8e), [`1479c87`](https://github.com/bluesky-social/atproto/commit/1479c87c6bf352694fdf298083e4f95287700c8e), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`d0c136c`](https://github.com/bluesky-social/atproto/commit/d0c136cba2ec8fa97017849b1023d5af5d2cc60c), [`527f5d4`](https://github.com/bluesky-social/atproto/commit/527f5d4c5d0c9264c2ff6f23ad06a41163fc6809), [`3aa18f8`](https://github.com/bluesky-social/atproto/commit/3aa18f84ef80a170b572076feeb649906ffcb06c)]: + - @atproto/syntax@0.5.2 + - @atproto/repo@0.9.0 + - @atproto/api@0.19.5 + - @atproto/sync@0.2.0 + - @atproto/xrpc-server@0.10.18 + - @atproto/lex@0.0.23 + +## 0.0.221 + +### Patch Changes + +- [#4747](https://github.com/bluesky-social/atproto/pull/4747) [`3b41b81`](https://github.com/bluesky-social/atproto/commit/3b41b81e27e0aba55406642c07da01c290281647) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Remove deprecated handling from `getSuggestedFollowsByActor` + +- [#4767](https://github.com/bluesky-social/atproto/pull/4767) [`4ecde48`](https://github.com/bluesky-social/atproto/commit/4ecde4879ffd769fe2c7a0f1d4e3275c776114f4) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Add feature gate to enable social proof on `getSuggestedFollowsByActor` + +- Updated dependencies []: + - @atproto/xrpc-server@0.10.17 + - @atproto/common@0.5.15 + +## 0.0.220 + +### Patch Changes + +- [#4712](https://github.com/bluesky-social/atproto/pull/4712) [`383e157`](https://github.com/bluesky-social/atproto/commit/383e157021564a6fb51baac584dd3e4f988f1d33) Thanks [@devinivy](https://github.com/devinivy)! - remove format from img urls by default + +- [#4723](https://github.com/bluesky-social/atproto/pull/4723) [`7ed5704`](https://github.com/bluesky-social/atproto/commit/7ed57043c12aedb0faf6b7dc947adfcfff570b6d) Thanks [@devinivy](https://github.com/devinivy)! - switch default image format to jpeg temporarily + +- [#4746](https://github.com/bluesky-social/atproto/pull/4746) [`eaee3d4`](https://github.com/bluesky-social/atproto/commit/eaee3d430554436964d45f38bbeb1132ae9b8862) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Serialize `pagerank` float values in `debug` field on profiles. + +- [#4753](https://github.com/bluesky-social/atproto/pull/4753) [`ff42a3a`](https://github.com/bluesky-social/atproto/commit/ff42a3afc3a0d4146a6618a910fa612c7e878ea7) Thanks [@rafaeleyng](https://github.com/rafaeleyng)! - Prefer returning `undefined` over year 0001 dates on bsky views + +- [#4762](https://github.com/bluesky-social/atproto/pull/4762) [`bc69b03`](https://github.com/bluesky-social/atproto/commit/bc69b03f53da3ec52bc3eed0738308f320386e75) Thanks [@rafaeleyng](https://github.com/rafaeleyng)! - Improve zero-date handling + +- [#4755](https://github.com/bluesky-social/atproto/pull/4755) [`139b294`](https://github.com/bluesky-social/atproto/commit/139b2941d640bafa1e7d3a56e0608dc42bb0006c) Thanks [@devinivy](https://github.com/devinivy)! - Remove feature gate for including format in image URLs. + +- Updated dependencies [[`9f9f71a`](https://github.com/bluesky-social/atproto/commit/9f9f71a6a3e58ccbd5e6d3ee079b570096cb11fa), [`67eb0c1`](https://github.com/bluesky-social/atproto/commit/67eb0c19ac415e762e221b2ccda9f0bcf7b3dd6f), [`192685f`](https://github.com/bluesky-social/atproto/commit/192685fca75a68c9c50a94817d3f27da7fc02f56)]: + - @atproto/api@0.19.4 + - @atproto/syntax@0.5.1 + - @atproto/repo@0.8.13 + - @atproto/xrpc-server@0.10.16 + +## 0.0.219 + +### Patch Changes + +- [#4683](https://github.com/bluesky-social/atproto/pull/4683) [`6634140`](https://github.com/bluesky-social/atproto/commit/66341400d49d1210619b000a040852d87085c32c) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Introduce recIdStr field + +- Updated dependencies [[`6634140`](https://github.com/bluesky-social/atproto/commit/66341400d49d1210619b000a040852d87085c32c), [`0e5df95`](https://github.com/bluesky-social/atproto/commit/0e5df95e3a8d81931524848d301cd43d1f12fb78)]: + - @atproto/api@0.19.2 + +## 0.0.218 + +### Patch Changes + +- [#4704](https://github.com/bluesky-social/atproto/pull/4704) [`137065b`](https://github.com/bluesky-social/atproto/commit/137065b333b8c9b97e6b3b2ac6147c7509a1ae42) Thanks [@ds-boyce](https://github.com/ds-boyce)! - Add feed to sendInteractions input + +- Updated dependencies [[`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`138f0a0`](https://github.com/bluesky-social/atproto/commit/138f0a0b374c0d78372d5095237061d46db75a32), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd), [`e8969b6`](https://github.com/bluesky-social/atproto/commit/e8969b6b3d3fed8912be53fd72b4d5288ca34766), [`52834ab`](https://github.com/bluesky-social/atproto/commit/52834aba182da8df3611fd9dff924e6c6a3973a7), [`137065b`](https://github.com/bluesky-social/atproto/commit/137065b333b8c9b97e6b3b2ac6147c7509a1ae42), [`f7c2610`](https://github.com/bluesky-social/atproto/commit/f7c26103a6d4e24e5bedbb6fd908be140420e0dd)]: + - @atproto/syntax@0.5.0 + - @atproto/common@0.5.14 + - @atproto/sync@0.1.40 + - @atproto/xrpc-server@0.10.15 + - @atproto/api@0.19.1 + - @atproto/lexicon@0.6.2 + +## 0.0.217 + +### Patch Changes + +- Updated dependencies [[`450f085`](https://github.com/bluesky-social/atproto/commit/450f0856630fa08c20dc60fef8b5d2a07b9a2552)]: + - @atproto/api@0.19.0 + +## 0.0.216 + +### Patch Changes + +- [#4647](https://github.com/bluesky-social/atproto/pull/4647) [`978a99e`](https://github.com/bluesky-social/atproto/commit/978a99efad8393247449bebd88af1ac5b602842e) Thanks [@estrattonbailey](https://github.com/estrattonbailey)! - Use correct `suggestionsAgent` method `getOnboardingSuggestedUsersSkeleton` + ## 0.0.215 ### Patch Changes diff --git a/packages/bsky/package.json b/packages/bsky/package.json index 0c41359c581..a611ed0cf1f 100644 --- a/packages/bsky/package.json +++ b/packages/bsky/package.json @@ -1,6 +1,6 @@ { "name": "@atproto/bsky", - "version": "0.0.215", + "version": "0.0.226", "license": "MIT", "description": "Reference implementation of app.bsky App View (Bluesky API)", "keywords": [ @@ -16,7 +16,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "codegen": "lex gen-server --yes ./src/lexicon ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/com/germnetwork/*", + "codegen": "lex build --override --indexFile --lexicons ../../lexicons", + "prebuild": "pnpm run codegen", "build": "tsc --build tsconfig.build.json", "start": "node --enable-source-maps dist/bin.js", "test": "../dev-infra/with-test-redis-and-db.sh jest", @@ -36,7 +37,7 @@ "@atproto/crypto": "workspace:^", "@atproto/did": "workspace:^", "@atproto/identity": "workspace:^", - "@atproto/lexicon": "workspace:^", + "@atproto/lex": "workspace:^", "@atproto/repo": "workspace:^", "@atproto/sync": "workspace:^", "@atproto/syntax": "workspace:^", @@ -61,7 +62,6 @@ "key-encoder": "^2.0.3", "kysely": "^0.22.0", "leo-profanity": "^1.8.0", - "multiformats": "^9.9.0", "murmurhash": "^2.0.1", "p-queue": "^6.6.2", "pg": "^8.10.0", @@ -75,10 +75,7 @@ "zod": "3.23.8" }, "devDependencies": { - "@atproto/api": "workspace:^", - "@atproto/lex-cli": "workspace:^", "@atproto/pds": "workspace:^", - "@atproto/xrpc": "workspace:^", "@bufbuild/buf": "^1.28.1", "@bufbuild/protoc-gen-es": "^1.5.0", "@connectrpc/protoc-gen-connect-es": "^1.1.4", diff --git a/packages/bsky/src/api/age-assurance/const.ts b/packages/bsky/src/api/age-assurance/const.ts index cfc34e6bd29..48bc650955a 100644 --- a/packages/bsky/src/api/age-assurance/const.ts +++ b/packages/bsky/src/api/age-assurance/const.ts @@ -1,7 +1,4 @@ -import { - AppBskyAgeassuranceDefs, - ageAssuranceRuleIDs as ids, -} from '@atproto/api' +import { app } from '../../lexicons/index.js' /** * Age assurance configuration defining rules for various regions. @@ -11,27 +8,24 @@ import { * * NOTE: all regions MUST have a default rule as the last rule. */ -export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { +export const AGE_ASSURANCE_CONFIG = app.bsky.ageassurance.defs.config.$build({ regions: [ { countryCode: 'GB', regionCode: undefined, minAccessAge: 13, rules: [ - { - $type: ids.IfAssuredOverAge, + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.IfDeclaredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.$build({ age: 13, access: 'safe', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, { @@ -39,30 +33,25 @@ export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { regionCode: undefined, minAccessAge: 16, rules: [ - { - $type: ids.IfAccountNewerThan, + app.bsky.ageassurance.defs.configRegionRuleIfAccountNewerThan.$build({ date: '2025-12-10T00:00:00Z', access: 'none', - }, - { - $type: ids.IfAssuredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.IfAssuredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 16, access: 'safe', - }, - { - $type: ids.IfDeclaredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.$build({ age: 16, access: 'safe', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, { @@ -70,20 +59,17 @@ export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { regionCode: 'SD', minAccessAge: 13, rules: [ - { - $type: ids.IfAssuredOverAge, + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.IfDeclaredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.$build({ age: 13, access: 'safe', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, { @@ -91,20 +77,17 @@ export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { regionCode: 'WY', minAccessAge: 13, rules: [ - { - $type: ids.IfAssuredOverAge, + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.IfDeclaredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.$build({ age: 13, access: 'safe', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, { @@ -112,20 +95,17 @@ export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { regionCode: 'OH', minAccessAge: 13, rules: [ - { - $type: ids.IfAssuredOverAge, + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.IfDeclaredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.$build({ age: 13, access: 'safe', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, { @@ -133,15 +113,13 @@ export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { regionCode: 'MS', minAccessAge: 18, rules: [ - { - $type: ids.IfAssuredOverAge, + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, { @@ -149,20 +127,17 @@ export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { regionCode: 'VA', minAccessAge: 16, rules: [ - { - $type: ids.IfAssuredOverAge, + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 16, access: 'full', - }, - { - $type: ids.IfDeclaredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.$build({ age: 16, access: 'full', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, { @@ -170,21 +145,18 @@ export const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = { regionCode: 'TN', minAccessAge: 18, rules: [ - { - $type: ids.IfAssuredOverAge, + app.bsky.ageassurance.defs.configRegionRuleIfAssuredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.IfDeclaredOverAge, + }), + app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.$build({ age: 18, access: 'full', - }, - { - $type: ids.Default, + }), + app.bsky.ageassurance.defs.configRegionRuleDefault.$build({ access: 'none', - }, + }), ], }, ], -} +}) diff --git a/packages/bsky/src/api/age-assurance/stash.ts b/packages/bsky/src/api/age-assurance/stash.ts index ab93be05577..a281fc5778a 100644 --- a/packages/bsky/src/api/age-assurance/stash.ts +++ b/packages/bsky/src/api/age-assurance/stash.ts @@ -1,15 +1,16 @@ import { TID } from '@atproto/common' +import { DatetimeString } from '@atproto/syntax' import { AppContext } from '../../context' -import { Event as AgeAssuranceEvent } from '../../lexicon/types/app/bsky/ageassurance/defs' +import { app } from '../../lexicons/index.js' import { Namespaces } from '../../stash' export async function createEvent( ctx: AppContext, actorDid: string, - event: Omit, + event: Omit, ) { - const payload: AgeAssuranceEvent = { - createdAt: new Date().toISOString(), + const payload: app.bsky.ageassurance.defs.Event = { + createdAt: new Date().toISOString() as DatetimeString, ...event, } await ctx.stashClient.create({ diff --git a/packages/bsky/src/api/age-assurance/util.ts b/packages/bsky/src/api/age-assurance/util.ts index 89f8e7687d0..0b617519696 100644 --- a/packages/bsky/src/api/age-assurance/util.ts +++ b/packages/bsky/src/api/age-assurance/util.ts @@ -1,15 +1,15 @@ import { - type AppBskyAgeassuranceDefs, computeAgeAssuranceRegionAccess, getAgeAssuranceRegionConfig, } from '@atproto/api' +import { app } from '../../lexicons/index.js' /** * Compute age assurance access based on verified minimum age. Thrown errors * are internal errors, so handle them accordingly. */ export function computeAgeAssuranceAccessOrThrow( - config: AppBskyAgeassuranceDefs.Config, + config: app.bsky.ageassurance.defs.Config, { countryCode, regionCode, diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index 3129db0e6ba..b387b8c3713 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -1,27 +1,29 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { DidString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, HydrationState, Hydrator, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfile' +import { app } from '../../../../lexicons/index.js' import { createPipeline, noRules } from '../../../../pipeline' import { Views } from '../../../../views' import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getProfile = createPipeline(skeleton, hydration, noRules, presentation) - server.app.bsky.actor.getProfile({ + server.add(app.bsky.actor.getProfile, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ auth, params, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getProfile({ ...params, hydrateCtx }, ctx) @@ -97,8 +99,8 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.actor.getProfile.$Params & { hydrateCtx: HydrateCtx } -type SkeletonState = { did: string } +type SkeletonState = { did: DidString } diff --git a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts index 9f7eb8b5ef2..50d14578063 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -1,36 +1,43 @@ import { mapDefined } from '@atproto/common' +import { DidString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, HydrationState, Hydrator, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { ids } from '../../../../lexicon/lexicons' -import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfiles' +import { app } from '../../../../lexicons/index.js' import { createPipeline, noRules } from '../../../../pipeline' import { Views } from '../../../../views' import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getProfile = createPipeline(skeleton, hydration, noRules, presentation) - server.app.bsky.actor.getProfiles({ + server.add(app.bsky.actor.getProfiles, { auth: ctx.authVerifier.standardOptionalParameterized({ lxmCheck: (method) => { if (!method) return false return ( - method === ids.AppBskyActorGetProfiles || + method === app.bsky.actor.getProfiles.$lxm || method.startsWith('chat.bsky.') ) }, }), + opts: { + // @TODO remove after grace period has passed, behavior is non-standard. + // temporarily added for compat w/ previous version of xrpc-server to avoid breakage of a few specified parties. + paramsParseLoose: true, + }, handler: async ({ auth, params, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers, includeTakedowns, + skipViewerBlocks, }) const result = await getProfile({ ...params, hydrateCtx }, ctx) @@ -85,8 +92,8 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.actor.getProfiles.$Params & { hydrateCtx: HydrateCtx } -type SkeletonState = { dids: string[] } +type SkeletonState = { dids: DidString[] } diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index b6f29a2ac25..3a2062c121a 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -1,6 +1,6 @@ -import { AtpAgent } from '@atproto/api' import { mapDefined, noUndefinedVals } from '@atproto/common' -import { HeadersMap } from '@atproto/xrpc' +import { Client, DidString, isDidString } from '@atproto/lex' +import { Headers as HeadersMap, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { @@ -9,8 +9,7 @@ import { Hydrator, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getSuggestions' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { Views } from '../../../../views' import { resHeaders } from '../../../util' @@ -22,7 +21,7 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrMutes, presentation, ) - server.app.bsky.actor.getSuggestions({ + server.add(app.bsky.actor.getSuggestions, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -39,7 +38,7 @@ export default function (server: Server, ctx: AppContext) { ctx, ) const suggestionsResHeaders = noUndefinedVals({ - 'content-language': resultHeaders?.['content-language'], + 'content-language': resultHeaders?.get('content-language'), }) return { encoding: 'application/json', @@ -59,20 +58,25 @@ const skeleton = async (input: { }): Promise => { const { ctx, params } = input const viewer = params.hydrateCtx.viewer - if (ctx.suggestionsAgent) { - const res = - await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton( - { + + if (viewer && ctx.suggestionsClient) { + const res = await ctx.suggestionsClient.xrpc( + app.bsky.unspecced.getSuggestionsSkeleton, + { + headers: params.headers, + params: { + relativeToDid: viewer, viewer: viewer ?? undefined, limit: params.limit, cursor: params.cursor, }, - { headers: params.headers }, - ) + }, + ) return { - dids: res.data.actors.map((a) => a.did), - cursor: res.data.cursor, - recId: res.data.recId, + dids: res.body.actors.map((a) => a.did), + cursor: res.body.cursor, + recId: res.body.recId, + recIdStr: res.body.recIdStr, resHeaders: res.headers, } } else { @@ -82,7 +86,8 @@ const skeleton = async (input: { cursor: params.cursor, limit: params.limit, }) - let dids = suggestions.dids + // @NOTE filtering to avoid type casting + let dids = suggestions.dids.filter(isDidString) if (viewer !== null) { const follows = await ctx.dataplane.getActorFollowsActors({ actorDid: viewer, @@ -132,25 +137,27 @@ const presentation = (input: { actors, cursor: skeleton.cursor, recId: skeleton.recId, + recIdStr: skeleton.recIdStr, resHeaders: skeleton.resHeaders, } } type Context = { - suggestionsAgent: AtpAgent | undefined + suggestionsClient: Client | undefined dataplane: DataPlaneClient hydrator: Hydrator views: Views } -type Params = QueryParams & { +type Params = app.bsky.actor.getSuggestions.$Params & { hydrateCtx: HydrateCtx headers: HeadersMap } type Skeleton = { - dids: string[] + dids: DidString[] cursor?: string recId?: number - resHeaders?: HeadersMap + recIdStr?: string + resHeaders?: Headers } diff --git a/packages/bsky/src/api/app/bsky/actor/searchActors.ts b/packages/bsky/src/api/app/bsky/actor/searchActors.ts index 88dc6213cdb..b2f193a99e8 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -1,11 +1,11 @@ -import { AtpAgent } from '@atproto/api' import { mapDefined } from '@atproto/common' +import { Client, DidString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/searchActors' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -23,15 +23,17 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.actor.searchActors({ + server.add(app.bsky.actor.searchActors, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers, includeTakedowns, + skipViewerBlocks, }) const results = await searchActors({ ...params, hydrateCtx }, ctx) return { @@ -43,24 +45,28 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: SkeletonFnInput) => { +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { const { ctx, params } = inputs const term = params.q ?? params.term ?? '' // @TODO // add hits total - if (ctx.searchAgent) { + if (ctx.searchClient) { // @NOTE cursors won't change on appview swap - const { data: res } = - await ctx.searchAgent.app.bsky.unspecced.searchActorsSkeleton({ + const res = await ctx.searchClient.call( + app.bsky.unspecced.searchActorsSkeleton, + { q: term, cursor: params.cursor, limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined, - }) + }, + ) return { - dids: res.actors.map(({ did }) => did), + dids: res.actors.map(({ did }) => did as DidString), cursor: parseString(res.cursor), } } @@ -71,7 +77,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { cursor: params.cursor, }) return { - dids: res.dids, + dids: res.dids as DidString[], cursor: parseString(res.cursor), } } @@ -108,13 +114,13 @@ type Context = { dataplane: DataPlaneClient hydrator: Hydrator views: Views - searchAgent?: AtpAgent + searchClient?: Client } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.actor.searchActors.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { - dids: string[] + dids: DidString[] hitsTotal?: number cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index 1808833d99f..a239912b0e9 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -1,11 +1,10 @@ -import { AtpAgent } from '@atproto/api' import { mapDefined } from '@atproto/common' +import { Client, DidString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/searchActorsTypeahead' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -23,7 +22,7 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.actor.searchActorsTypeahead({ + server.add(app.bsky.actor.searchActorsTypeahead, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -42,7 +41,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: SkeletonFnInput) => { +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { const { ctx, params } = inputs const term = params.q ?? params.term ?? '' @@ -50,17 +51,18 @@ const skeleton = async (inputs: SkeletonFnInput) => { // add typeahead option // add hits total - if (ctx.searchAgent) { - const { data: res } = - await ctx.searchAgent.app.bsky.unspecced.searchActorsSkeleton({ + if (ctx.searchClient) { + const { actors } = await ctx.searchClient.call( + app.bsky.unspecced.searchActorsSkeleton, + { typeahead: true, q: term, limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined, - }) + }, + ) return { - dids: res.actors.map(({ did }) => did), - cursor: parseString(res.cursor), + dids: actors.map(({ did }) => did), } } @@ -69,8 +71,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { limit: params.limit, }) return { - dids: res.dids, - cursor: parseString(res.cursor), + dids: res.dids as DidString[], } } @@ -110,11 +111,13 @@ type Context = { dataplane: DataPlaneClient hydrator: Hydrator views: Views - searchAgent?: AtpAgent + searchClient?: Client } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.actor.searchActorsTypeahead.$Params & { + hydrateCtx: HydrateCtx +} type Skeleton = { - dids: string[] + dids: DidString[] } diff --git a/packages/bsky/src/api/app/bsky/ageassurance/begin.ts b/packages/bsky/src/api/app/bsky/ageassurance/begin.ts index c55ce3b5967..047caa0c4da 100644 --- a/packages/bsky/src/api/app/bsky/ageassurance/begin.ts +++ b/packages/bsky/src/api/app/bsky/ageassurance/begin.ts @@ -5,10 +5,11 @@ import { getAgeAssuranceRegionConfig } from '@atproto/api' import { InvalidRequestError, MethodNotImplementedError, + Server, } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { InputSchema } from '../../../../lexicon/types/app/bsky/ageassurance/begin' +type InputSchema = app.bsky.ageassurance.begin.$InputBody +import { app } from '../../../../lexicons/index.js' import { httpLogger as log } from '../../../../logger' import { ActorInfo } from '../../../../proto/bsky_pb' import { AGE_ASSURANCE_CONFIG } from '../../../age-assurance/const' @@ -26,7 +27,7 @@ import { createLocationString } from '../../../age-assurance/util' import { getClientUa } from '../../../kws/util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.ageassurance.begin({ + server.add(app.bsky.ageassurance.begin, { auth: ctx.authVerifier.standard, handler: async ({ auth, input, req }) => { if (!ctx.kwsClient) { diff --git a/packages/bsky/src/api/app/bsky/ageassurance/getConfig.ts b/packages/bsky/src/api/app/bsky/ageassurance/getConfig.ts index d54485f0e52..f456a86ab60 100644 --- a/packages/bsky/src/api/app/bsky/ageassurance/getConfig.ts +++ b/packages/bsky/src/api/app/bsky/ageassurance/getConfig.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AGE_ASSURANCE_CONFIG } from '../../../../api/age-assurance/const' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.app.bsky.ageassurance.getConfig({ + server.add(app.bsky.ageassurance.getConfig, { auth: ctx.authVerifier.standardOptional, handler: async () => { return { diff --git a/packages/bsky/src/api/app/bsky/ageassurance/getState.ts b/packages/bsky/src/api/app/bsky/ageassurance/getState.ts index 648b079a5b2..a053c0b27fa 100644 --- a/packages/bsky/src/api/app/bsky/ageassurance/getState.ts +++ b/packages/bsky/src/api/app/bsky/ageassurance/getState.ts @@ -1,29 +1,34 @@ -import { UpstreamFailureError } from '@atproto/xrpc-server' +import { DatetimeString } from '@atproto/syntax' +import { Server, UpstreamFailureError } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { ActorInfo } from '../../../../proto/bsky_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.ageassurance.getState({ + server.add(app.bsky.ageassurance.getState, { auth: ctx.authVerifier.standard, - handler: async ({ auth }) => { + handler: async ({ + auth, + }): Promise => { const viewer = auth.credentials.iss const actor = await getActorInfo(ctx, viewer) + const lastInitiatedAt = actor.ageAssuranceStatus?.lastInitiatedAt + return { encoding: 'application/json', body: { state: { - lastInitiatedAt: - actor.ageAssuranceStatus?.lastInitiatedAt - ?.toDate() - .toISOString() || undefined, + lastInitiatedAt: lastInitiatedAt + ? (lastInitiatedAt.toDate().toISOString() as DatetimeString) + : undefined, status: actor.ageAssuranceStatus?.status || 'unknown', access: actor.ageAssuranceStatus?.access || 'unknown', }, metadata: { - accountCreatedAt: - actor.createdAt?.toDate().toISOString() || undefined, + accountCreatedAt: actor.createdAt + ? (actor.createdAt.toDate().toISOString() as DatetimeString) + : undefined, }, }, } diff --git a/packages/bsky/src/api/app/bsky/bookmark/createBookmark.ts b/packages/bsky/src/api/app/bsky/bookmark/createBookmark.ts index 0724dd8ca1e..3a21671db45 100644 --- a/packages/bsky/src/api/app/bsky/bookmark/createBookmark.ts +++ b/packages/bsky/src/api/app/bsky/bookmark/createBookmark.ts @@ -1,12 +1,12 @@ import { TID } from '@atproto/common' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { Bookmark } from '../../../../lexicon/types/app/bsky/bookmark/defs' +import { app } from '../../../../lexicons/index.js' import { Namespaces } from '../../../../stash' import { validateUri } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.bookmark.createBookmark({ + server.add(app.bsky.bookmark.createBookmark, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const actorDid = auth.credentials.iss @@ -31,7 +31,7 @@ export default function (server: Server, ctx: AppContext) { cid, uri, }, - } satisfies Bookmark, + } satisfies app.bsky.bookmark.defs.Bookmark, key: TID.nextStr(), }) }, diff --git a/packages/bsky/src/api/app/bsky/bookmark/deleteBookmark.ts b/packages/bsky/src/api/app/bsky/bookmark/deleteBookmark.ts index 33af87f001f..4d4f03e88c9 100644 --- a/packages/bsky/src/api/app/bsky/bookmark/deleteBookmark.ts +++ b/packages/bsky/src/api/app/bsky/bookmark/deleteBookmark.ts @@ -1,10 +1,11 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { Namespaces } from '../../../../stash' import { validateUri } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.bookmark.deleteBookmark({ + server.add(app.bsky.bookmark.deleteBookmark, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const actorDid = auth.credentials.iss diff --git a/packages/bsky/src/api/app/bsky/bookmark/getBookmarks.ts b/packages/bsky/src/api/app/bsky/bookmark/getBookmarks.ts index df86c89f652..01b035734fa 100644 --- a/packages/bsky/src/api/app/bsky/bookmark/getBookmarks.ts +++ b/packages/bsky/src/api/app/bsky/bookmark/getBookmarks.ts @@ -1,8 +1,8 @@ import { mapDefined } from '@atproto/common' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/bookmark/getBookmarks' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -21,7 +21,7 @@ export default function (server: Server, ctx: AppContext) { noRules, // Blocks are included and handled on views. Mutes are included. presentation, ) - server.app.bsky.bookmark.getBookmarks({ + server.add(app.bsky.bookmark.getBookmarks, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -31,10 +31,7 @@ export default function (server: Server, ctx: AppContext) { viewer, }) - const result = await getBookmarks( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getBookmarks({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', @@ -86,8 +83,8 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.bookmark.getBookmarks.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { diff --git a/packages/bsky/src/api/app/bsky/bookmark/util.ts b/packages/bsky/src/api/app/bsky/bookmark/util.ts index 05d4f5eeca8..3d4fe7d8c3a 100644 --- a/packages/bsky/src/api/app/bsky/bookmark/util.ts +++ b/packages/bsky/src/api/app/bsky/bookmark/util.ts @@ -1,12 +1,12 @@ import { AtUri } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' -import { ids } from '../../../../lexicon/lexicons' +import { app } from '../../../../lexicons/index.js' export const validateUri = (uri: string) => { const atUri = new AtUri(uri) - if (atUri.collection !== ids.AppBskyFeedPost) { + if (atUri.collection !== app.bsky.feed.post.$type) { throw new InvalidRequestError( - `Only '${ids.AppBskyFeedPost}' records can be bookmarked`, + `Only '${app.bsky.feed.post.$type}' records can be bookmarked`, 'UnsupportedCollection', ) } diff --git a/packages/bsky/src/api/app/bsky/contact/dismissMatch.ts b/packages/bsky/src/api/app/bsky/contact/dismissMatch.ts index 9aecb4388c6..f58a4149652 100644 --- a/packages/bsky/src/api/app/bsky/contact/dismissMatch.ts +++ b/packages/bsky/src/api/app/bsky/contact/dismissMatch.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { assertRolodexOrThrowUnimplemented, callRolodexClient } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.contact.dismissMatch({ + server.add(app.bsky.contact.dismissMatch, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { assertRolodexOrThrowUnimplemented(ctx) diff --git a/packages/bsky/src/api/app/bsky/contact/getMatches.ts b/packages/bsky/src/api/app/bsky/contact/getMatches.ts index d7e50925ec9..b1c36a5c1c8 100644 --- a/packages/bsky/src/api/app/bsky/contact/getMatches.ts +++ b/packages/bsky/src/api/app/bsky/contact/getMatches.ts @@ -1,12 +1,13 @@ import { mapDefined } from '@atproto/common' +import { DidString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { - HydrateCtx, + HydrateCtxWithViewer, HydrationState, Hydrator, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/contact/getMatches' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, SkeletonFnInput, @@ -18,7 +19,8 @@ import { assertRolodexOrThrowUnimplemented, callRolodexClient } from './util' export default function (server: Server, ctx: AppContext) { const getMatches = createPipeline(skeleton, hydration, noBlocks, presentation) - server.app.bsky.contact.getMatches({ + + server.add(app.bsky.contact.getMatches, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { assertRolodexOrThrowUnimplemented(ctx) @@ -30,10 +32,7 @@ export default function (server: Server, ctx: AppContext) { viewer, }) - const result = await getMatches( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getMatches({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', @@ -57,7 +56,7 @@ const skeleton = async ( ) return { actor, - subjects, + subjects: subjects as DidString[], cursor: cursor || undefined, } } @@ -101,12 +100,12 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.contact.getMatches.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { actor: string - subjects: string[] + subjects: DidString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/contact/getSyncStatus.ts b/packages/bsky/src/api/app/bsky/contact/getSyncStatus.ts index 589dabcffd4..6036f28bd46 100644 --- a/packages/bsky/src/api/app/bsky/contact/getSyncStatus.ts +++ b/packages/bsky/src/api/app/bsky/contact/getSyncStatus.ts @@ -1,10 +1,11 @@ +import { DatetimeString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { SyncStatus } from '../../../../lexicon/types/app/bsky/contact/defs' +import { app } from '../../../../lexicons/index.js' import { assertRolodexOrThrowUnimplemented, callRolodexClient } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.contact.getSyncStatus({ + server.add(app.bsky.contact.getSyncStatus, { auth: ctx.authVerifier.standard, handler: async ({ auth }) => { assertRolodexOrThrowUnimplemented(ctx) @@ -16,14 +17,15 @@ export default function (server: Server, ctx: AppContext) { }), ) - let syncStatus: SyncStatus | undefined - if (res.status && res.status.syncedAt) { - const syncedAt = res.status?.syncedAt?.toDate().toISOString() - syncStatus = { - matchesCount: res.status.matchesCount, - syncedAt, - } - } + const syncStatus: app.bsky.contact.defs.SyncStatus | undefined = + res.status && res.status.syncedAt + ? { + matchesCount: res.status.matchesCount, + syncedAt: res.status.syncedAt + .toDate() + .toISOString() as DatetimeString, + } + : undefined return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/contact/importContacts.ts b/packages/bsky/src/api/app/bsky/contact/importContacts.ts index 4d4e03510a1..afc09316800 100644 --- a/packages/bsky/src/api/app/bsky/contact/importContacts.ts +++ b/packages/bsky/src/api/app/bsky/contact/importContacts.ts @@ -1,13 +1,13 @@ import { mapDefined } from '@atproto/common' +import { DidString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, HydrationState, Hydrator, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { MatchAndContactIndex } from '../../../../lexicon/types/app/bsky/contact/defs' -import { InputSchema } from '../../../../lexicon/types/app/bsky/contact/importContacts' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, SkeletonFnInput, @@ -26,7 +26,7 @@ export default function (server: Server, ctx: AppContext) { noRules, // presentation, ) - server.app.bsky.contact.importContacts({ + server.add(app.bsky.contact.importContacts, { auth: ctx.authVerifier.standard, handler: async ({ input, auth, req }) => { assertRolodexOrThrowUnimplemented(ctx) @@ -38,10 +38,7 @@ export default function (server: Server, ctx: AppContext) { viewer, }) - const result = await importContacts( - { ...input.body, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await importContacts({ ...input.body, hydrateCtx }, ctx) return { encoding: 'application/json', @@ -74,7 +71,7 @@ const hydration = async ( ) => { const { ctx, params, skeleton } = input const { matches } = skeleton - const subjects = matches.map((m) => m.subject) + const subjects = matches.map((m) => m.subject as DidString) return ctx.hydrator.hydrateProfiles(subjects, params.hydrateCtx) } @@ -87,8 +84,11 @@ const presentation = (input: { const { ctx, skeleton, hydration } = input const matchesAndContactIndexes = mapDefined( skeleton.matches, - ({ subject, inputIndex }): MatchAndContactIndex | undefined => { - const profile = ctx.views.profile(subject, hydration) + ({ + subject, + inputIndex, + }): app.bsky.contact.defs.MatchAndContactIndex | undefined => { + const profile = ctx.views.profile(subject as DidString, hydration) if (!profile) { return undefined @@ -109,7 +109,7 @@ type Context = { views: Views } -type Params = InputSchema & { +type Params = app.bsky.contact.importContacts.$InputBody & { hydrateCtx: HydrateCtx & { viewer: string } } diff --git a/packages/bsky/src/api/app/bsky/contact/removeData.ts b/packages/bsky/src/api/app/bsky/contact/removeData.ts index cd7b400c560..9827be14769 100644 --- a/packages/bsky/src/api/app/bsky/contact/removeData.ts +++ b/packages/bsky/src/api/app/bsky/contact/removeData.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { assertRolodexOrThrowUnimplemented, callRolodexClient } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.contact.removeData({ + server.add(app.bsky.contact.removeData, { auth: ctx.authVerifier.standard, handler: async ({ auth }) => { assertRolodexOrThrowUnimplemented(ctx) diff --git a/packages/bsky/src/api/app/bsky/contact/sendNotification.ts b/packages/bsky/src/api/app/bsky/contact/sendNotification.ts index 264b0ca6cf4..b0914ae6def 100644 --- a/packages/bsky/src/api/app/bsky/contact/sendNotification.ts +++ b/packages/bsky/src/api/app/bsky/contact/sendNotification.ts @@ -1,12 +1,12 @@ import { TID } from '@atproto/common' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { Notification } from '../../../../lexicon/types/app/bsky/contact/defs' +import { app } from '../../../../lexicons/index.js' import { Namespaces } from '../../../../stash' import { assertRolodexOrThrowUnimplemented } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.contact.sendNotification({ + server.add(app.bsky.contact.sendNotification, { auth: ctx.authVerifier.role, handler: async ({ input }) => { // Assert rolodex even though we don't call it, it is a proxy to whether the app is configured with contact import support. @@ -20,7 +20,7 @@ export default function (server: Server, ctx: AppContext) { payload: { from, to, - } satisfies Notification, + } satisfies app.bsky.contact.defs.Notification, key: TID.nextStr(), }) diff --git a/packages/bsky/src/api/app/bsky/contact/startPhoneVerification.ts b/packages/bsky/src/api/app/bsky/contact/startPhoneVerification.ts index c6f05edb2f3..172e04b0a85 100644 --- a/packages/bsky/src/api/app/bsky/contact/startPhoneVerification.ts +++ b/packages/bsky/src/api/app/bsky/contact/startPhoneVerification.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { assertRolodexOrThrowUnimplemented, callRolodexClient } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.contact.startPhoneVerification({ + server.add(app.bsky.contact.startPhoneVerification, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { assertRolodexOrThrowUnimplemented(ctx) diff --git a/packages/bsky/src/api/app/bsky/contact/verifyPhone.ts b/packages/bsky/src/api/app/bsky/contact/verifyPhone.ts index 3288eb9c81d..be24a051bf0 100644 --- a/packages/bsky/src/api/app/bsky/contact/verifyPhone.ts +++ b/packages/bsky/src/api/app/bsky/contact/verifyPhone.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { assertRolodexOrThrowUnimplemented, callRolodexClient } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.contact.verifyPhone({ + server.add(app.bsky.contact.verifyPhone, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { assertRolodexOrThrowUnimplemented(ctx) diff --git a/packages/bsky/src/api/app/bsky/draft/createDraft.ts b/packages/bsky/src/api/app/bsky/draft/createDraft.ts index 0e7e65fdcd3..eb59649212e 100644 --- a/packages/bsky/src/api/app/bsky/draft/createDraft.ts +++ b/packages/bsky/src/api/app/bsky/draft/createDraft.ts @@ -1,12 +1,11 @@ import { TID } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { DraftWithId } from '../../../../lexicon/types/app/bsky/draft/defs' +import { app } from '../../../../lexicons/index.js' import { Namespaces } from '../../../../stash' export default function (server: Server, ctx: AppContext) { - server.app.bsky.draft.createDraft({ + server.add(app.bsky.draft.createDraft, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const actorDid = auth.credentials.iss @@ -25,7 +24,7 @@ export default function (server: Server, ctx: AppContext) { } const draftId = TID.nextStr() - const draftWithId: DraftWithId = { + const draftWithId: app.bsky.draft.defs.DraftWithId = { id: draftId, draft, } diff --git a/packages/bsky/src/api/app/bsky/draft/deleteDraft.ts b/packages/bsky/src/api/app/bsky/draft/deleteDraft.ts index 6b34d262e9b..c3c4caf37f9 100644 --- a/packages/bsky/src/api/app/bsky/draft/deleteDraft.ts +++ b/packages/bsky/src/api/app/bsky/draft/deleteDraft.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons' import { Namespaces } from '../../../../stash' export default function (server: Server, ctx: AppContext) { - server.app.bsky.draft.deleteDraft({ + server.add(app.bsky.draft.deleteDraft, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const actorDid = auth.credentials.iss diff --git a/packages/bsky/src/api/app/bsky/draft/getDrafts.ts b/packages/bsky/src/api/app/bsky/draft/getDrafts.ts index 23cbeb450fa..ec1cb032908 100644 --- a/packages/bsky/src/api/app/bsky/draft/getDrafts.ts +++ b/packages/bsky/src/api/app/bsky/draft/getDrafts.ts @@ -1,13 +1,10 @@ -import { jsonStringToLex } from '@atproto/lexicon' +import { DatetimeString, lexParse } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { - DraftView, - DraftWithId, -} from '../../../../lexicon/types/app/bsky/draft/defs' +import { app } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.app.bsky.draft.getDrafts({ + server.add(app.bsky.draft.getDrafts, { auth: ctx.authVerifier.standard, handler: async ({ params, auth }) => { const viewer = auth.credentials.iss @@ -18,19 +15,20 @@ export default function (server: Server, ctx: AppContext) { cursor: params.cursor, }) - const draftViews = drafts.map((d): DraftView => { - const draftWithId = jsonStringToLex( - Buffer.from(d.payload).toString('utf8'), - ) as DraftWithId + const draftViews = drafts.map((d): app.bsky.draft.defs.DraftView => { + const jsonStr = Buffer.from(d.payload).toString('utf8') + const draftWithId = lexParse(jsonStr) return { id: draftWithId.id, draft: draftWithId.draft, // The date should always be present, but we avoid required fields on protobuf by convention, // so requires a fallback value to please TS. - createdAt: - d.createdAt?.toDate().toISOString() ?? new Date(0).toISOString(), - updatedAt: - d.updatedAt?.toDate().toISOString() ?? new Date(0).toISOString(), + createdAt: ( + d.createdAt?.toDate() ?? new Date(0) + ).toISOString() as DatetimeString, + updatedAt: ( + d.updatedAt?.toDate() ?? new Date(0) + ).toISOString() as DatetimeString, } }) diff --git a/packages/bsky/src/api/app/bsky/draft/updateDraft.ts b/packages/bsky/src/api/app/bsky/draft/updateDraft.ts index 5c576382a82..36ac696a011 100644 --- a/packages/bsky/src/api/app/bsky/draft/updateDraft.ts +++ b/packages/bsky/src/api/app/bsky/draft/updateDraft.ts @@ -1,10 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { DraftWithId } from '../../../../lexicon/types/app/bsky/draft/defs' +import { app } from '../../../../lexicons/index.js' import { Namespaces } from '../../../../stash' export default function (server: Server, ctx: AppContext) { - server.app.bsky.draft.updateDraft({ + server.add(app.bsky.draft.updateDraft, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const actorDid = auth.credentials.iss @@ -18,7 +18,7 @@ export default function (server: Server, ctx: AppContext) { await ctx.stashClient.update({ actorDid, namespace: Namespaces.AppBskyDraftDefsDraftWithId, - payload: draftWithId satisfies DraftWithId, + payload: draftWithId satisfies app.bsky.draft.defs.DraftWithId, key: draftWithId.id, }) }, diff --git a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts index b71bead2d9e..ce46f1a1cfe 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts @@ -1,5 +1,6 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { @@ -8,8 +9,7 @@ import { Hydrator, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorFeeds' +import { app } from '../../../../lexicons/index.js' import { createPipeline, noRules } from '../../../../pipeline' import { Views } from '../../../../views' import { clearlyBadCursor, resHeaders } from '../../../util' @@ -21,7 +21,7 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.feed.getActorFeeds({ + server.add(app.bsky.feed.getActorFeeds, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -55,7 +55,7 @@ const skeleton = async (inputs: { limit: params.limit, }) return { - feedUris: feedsRes.uris, + feedUris: feedsRes.uris as AtUriString[], cursor: parseString(feedsRes.cursor), } } @@ -93,9 +93,9 @@ type Context = { dataplane: DataPlaneClient } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getActorFeeds.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { - feedUris: string[] + feedUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 1c83ddccab2..6f53bbc3adc 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -1,5 +1,6 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { FeedItem } from '../../../../hydration/feed' @@ -9,8 +10,7 @@ import { Hydrator, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorLikes' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { uriToDid as creatorFromUri } from '../../../../util/uris' import { Views } from '../../../../views' @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { noPostBlocks, presentation, ) - server.app.bsky.feed.getActorLikes({ + server.add(app.bsky.feed.getActorLikes, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -67,7 +67,9 @@ const skeleton = async (inputs: { cursor, }) - const items = likesRes.likes.map((l) => ({ post: { uri: l.subject } })) + const items = likesRes.likes.map((l) => ({ + post: { uri: l.subject as AtUriString }, + })) return { items, @@ -118,7 +120,7 @@ type Context = { dataplane: DataPlaneClient } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getActorLikes.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { items: FeedItem[] diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index fda1a99077c..8ec4d2a99f9 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -1,5 +1,6 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { Actor } from '../../../../hydration/actor' @@ -11,8 +12,7 @@ import { mergeStates, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { FeedType } from '../../../../proto/bsky_pb' import { safePinnedPost, uriToDid } from '../../../../util/uris' @@ -26,15 +26,17 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrMutedReposts, presentation, ) - server.app.bsky.feed.getAuthorFeed({ + server.add(app.bsky.feed.getAuthorFeed, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getAuthorFeed({ ...params, hydrateCtx }, ctx) @@ -98,9 +100,9 @@ export const skeleton = async (inputs: { }) let items: FeedItem[] = res.items.map((item) => ({ - post: { uri: item.uri, cid: item.cid || undefined }, + post: { uri: item.uri as AtUriString, cid: item.cid || undefined }, repost: item.repost - ? { uri: item.repost, cid: item.repostCid || undefined } + ? { uri: item.repost as AtUriString, cid: item.repostCid || undefined } : undefined, })) @@ -208,20 +210,20 @@ type Context = { dataplane: DataPlaneClient } -type Params = QueryParams & { +type Params = app.bsky.feed.getAuthorFeed.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { actor: Actor items: FeedItem[] - filter: QueryParams['filter'] + filter: app.bsky.feed.getAuthorFeed.$Params['filter'] cursor?: string } class SelfThreadTracker { - feedUris = new Set() - cache = new Map() + feedUris = new Set() + cache = new Map() constructor( items: FeedItem[], @@ -234,7 +236,7 @@ class SelfThreadTracker { }) } - ok(uri: string, loop = new Set()) { + ok(uri: AtUriString, loop = new Set()) { // if we've already checked this uri, pull from the cache if (this.cache.has(uri)) { return this.cache.get(uri) ?? false @@ -252,7 +254,7 @@ class SelfThreadTracker { return result } - private _ok(uri: string, loop: Set): boolean { + private _ok(uri: AtUriString, loop: Set): boolean { // must be in the feed to be in a self-thread if (!this.feedUris.has(uri)) { return false diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 2847c1f0615..25abc365993 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -1,10 +1,16 @@ -import { AppBskyFeedGetFeedSkeleton, AtpAgent } from '@atproto/api' import { mapDefined, noUndefinedVals } from '@atproto/common' -import { ResponseType, XRPCError } from '@atproto/xrpc' import { + XrpcInvalidResponseError, + XrpcResponseError, + xrpcSafe, +} from '@atproto/lex' +import { + Headers as HeadersMap, InvalidRequestError, + Server, ServerTimer, UpstreamFailureError, + XRPCError, serverTimingHeader, } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' @@ -16,11 +22,7 @@ import { } from '../../../../data-plane' import { FeedItem } from '../../../../hydration/feed' import { HydrateCtx } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { ids } from '../../../../lexicon/lexicons' -import { isSkeletonReasonRepost } from '../../../../lexicon/types/app/bsky/feed/defs' -import { QueryParams as GetFeedParams } from '../../../../lexicon/types/app/bsky/feed/getFeed' -import { OutputSchema as SkeletonOutput } from '../../../../lexicon/types/app/bsky/feed/getFeedSkeleton' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -38,12 +40,12 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrMutes, presentation, ) - server.app.bsky.feed.getFeed({ + server.add(app.bsky.feed.getFeed, { auth: ctx.authVerifier.standardOptionalParameterized({ lxmCheck: (method) => { return ( - method === ids.AppBskyFeedGetFeedSkeleton || - method === ids.AppBskyFeedGetFeed + method === app.bsky.feed.getFeedSkeleton.$lxm || + method === app.bsky.feed.getFeed.$lxm ) }, skipAudCheck: true, @@ -72,7 +74,7 @@ export default function (server: Server, ctx: AppContext) { encoding: 'application/json', body: result, headers: { - ...(feedResHeaders ?? {}), + ...feedResHeaders, ...resHeaders({ labelers: hydrateCtx.labelers }), 'server-timing': serverTimingHeader([timerSkele, timerHydr]), }, @@ -158,16 +160,16 @@ const presentation = ( type Context = AppContext -type Params = GetFeedParams & { +type Params = app.bsky.feed.getFeed.$Params & { hydrateCtx: HydrateCtx - headers: Record + headers: HeadersMap } type Skeleton = { items: AlgoResponseItem[] reqId?: string passthrough: Record // pass through additional items in feedgen response - resHeaders?: Record + resHeaders?: HeadersMap cursor?: string timerSkele: ServerTimer timerHydr: ServerTimer @@ -205,69 +207,66 @@ const skeletonFromFeedGen = async ( ) } - const agent = new AtpAgent({ service: fgEndpoint }) - - let skeleton: SkeletonOutput - let resHeaders: Record | undefined = undefined - try { - // @TODO currently passthrough auth headers from pds - const result = await agent.api.app.bsky.feed.getFeedSkeleton( - { - feed: params.feed, - // The feedgen is not guaranteed to honor the limit, but we try it. - limit: params.limit, - cursor: params.cursor, - }, - { - headers, - }, - ) + // @TODO currently passthrough auth headers from pds + const result = await xrpcSafe(fgEndpoint, app.bsky.feed.getFeedSkeleton, { + strictResponseProcessing: false, + headers, + params: { + feed: params.feed, + // The feedgen is not guaranteed to honor the limit, but we try it. + limit: params.limit, + cursor: params.cursor, + }, + }) - skeleton = result.data + if (!result.success) { + const cause = result.reason - if (result.data.cursor === params.cursor) { - // Prevents loops if the custom feed echoes the input cursor back. - skeleton.cursor = undefined + // Pass through structurally valid XRPC error response (4xx/5xx), such as + // auth errors + if (cause instanceof XrpcResponseError) { + const { status, body } = cause.toDownstreamError() + throw new XRPCError(status, body.message, body.error, { cause }) } - if (result.headers['content-language']) { - resHeaders = { - 'content-language': result.headers['content-language'], - } - } - } catch (err) { - if (err instanceof AppBskyFeedGetFeedSkeleton.UnknownFeedError) { - throw new InvalidRequestError(err.message, 'UnknownFeed') - } - if (err instanceof XRPCError) { - if (err.status === ResponseType.Unknown) { - throw new UpstreamFailureError('feed unavailable') - } - if (err.status === ResponseType.InvalidResponse) { - throw new UpstreamFailureError( - 'feed provided an invalid response', - 'InvalidFeedResponse', - ) - } + // The response does not match the schema + if (cause instanceof XrpcInvalidResponseError) { + throw new UpstreamFailureError( + 'feed provided an invalid response', + 'InvalidFeedResponse', + { cause }, + ) } - throw err + + // Typically a network error. + throw new UpstreamFailureError('feed unavailable', undefined, { cause }) } - const { feed: feedSkele, ...skele } = skeleton + const { feed: feedSkele, cursor, ...skele } = result.body const feedItems = feedSkele.slice(0, params.limit).map((item) => ({ post: { uri: item.post }, - repost: isSkeletonReasonRepost(item.reason) - ? { uri: item.reason.repost } - : undefined, + repost: + item.reason != null && + app.bsky.feed.defs.skeletonReasonRepost.$isTypeOf(item.reason) + ? { uri: item.reason.repost } + : undefined, feedContext: item.feedContext, })) - return { ...skele, resHeaders, feedItems } + const contentLang = result.headers.get('content-language') + + return { + ...skele, + resHeaders: contentLang ? { 'content-language': contentLang } : undefined, + feedItems, + // Prevents loops if the custom feed echoes the input cursor back. + cursor: cursor === params.cursor ? undefined : cursor, + } } export type AlgoResponse = { feedItems: AlgoResponseItem[] - resHeaders?: Record + resHeaders?: HeadersMap cursor?: string reqId?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 12ce1d4dc5c..24a6ceb653c 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts @@ -1,4 +1,4 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { Code, @@ -6,12 +6,12 @@ import { isDataplaneError, unpackIdentityServices, } from '../../../../data-plane' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { GetIdentityByDidResponse } from '../../../../proto/bsky_pb' import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.feed.getFeedGenerator({ + server.add(app.bsky.feed.getFeedGenerator, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { const { feed } = params diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts index 9f8dea404a9..e09529e63f1 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,12 +1,13 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, HydrationState, Hydrator, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getFeedGenerators' +import { app } from '../../../../lexicons/index.js' import { createPipeline, noRules } from '../../../../pipeline' import { Views } from '../../../../views' import { resHeaders } from '../../../util' @@ -18,8 +19,13 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.feed.getFeedGenerators({ + server.add(app.bsky.feed.getFeedGenerators, { auth: ctx.authVerifier.standardOptional, + opts: { + // @TODO remove after grace period has passed, behavior is non-standard. + // temporarily added for compat w/ previous version of xrpc-server to avoid breakage of a few specified parties. + paramsParseLoose: true, + }, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) @@ -71,8 +77,10 @@ type Context = { views: Views } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getFeedGenerators.$Params & { + hydrateCtx: HydrateCtx +} type Skeleton = { - feedUris: string[] + feedUris: AtUriString[] } diff --git a/packages/bsky/src/api/app/bsky/feed/getLikes.ts b/packages/bsky/src/api/app/bsky/feed/getLikes.ts index 4e09bf00f00..ace3b46ba34 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -1,6 +1,11 @@ import { mapDefined } from '@atproto/common' -import { normalizeDatetimeAlways } from '@atproto/syntax' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { + AtUriString, + DatetimeString, + DidString, + normalizeDatetimeAlways, +} from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, @@ -8,8 +13,7 @@ import { Hydrator, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getLikes' +import { app } from '../../../../lexicons/index.js' import { RulesFnInput, createPipeline } from '../../../../pipeline' import { uriToDid as creatorFromUri } from '../../../../util/uris' import { Views } from '../../../../views' @@ -17,15 +21,17 @@ import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getLikes = createPipeline(skeleton, hydration, noBlocks, presentation) - server.app.bsky.feed.getLikes({ + server.add(app.bsky.feed.getLikes, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getLikes({ ...params, hydrateCtx }, ctx) @@ -60,7 +66,7 @@ const skeleton = async (inputs: { }) return { authorDid, - likes: likesRes.uris, + likes: likesRes.uris as AtUriString[], cursor: parseString(likesRes.cursor), } } @@ -99,7 +105,7 @@ const presentation = (inputs: { params: Params skeleton: Skeleton hydration: HydrationState -}) => { +}): app.bsky.feed.getLikes.$OutputBody => { const { ctx, params, skeleton, hydration } = inputs const likeViews = mapDefined(skeleton.likes, (uri) => { const like = hydration.likes?.get(uri) @@ -114,7 +120,7 @@ const presentation = (inputs: { return { actor, createdAt: normalizeDatetimeAlways(like.record.createdAt), - indexedAt: like.sortedAt.toISOString(), + indexedAt: like.sortedAt.toISOString() as DatetimeString, } }) return { @@ -130,11 +136,11 @@ type Context = { views: Views } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getLikes.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { - authorDid: string - likes: string[] + authorDid: DidString + likes: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index 7bea5e6a11a..a3ccd708692 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -1,4 +1,6 @@ import { mapDefined } from '@atproto/common' +import { AtUriString, DidString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { FeedItem } from '../../../../hydration/feed' @@ -9,8 +11,7 @@ import { mergeStates, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getListFeed' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { uriToDid } from '../../../../util/uris' import { Views } from '../../../../views' @@ -23,7 +24,7 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrMutes, presentation, ) - server.app.bsky.feed.getListFeed({ + server.add(app.bsky.feed.getListFeed, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -58,9 +59,9 @@ export const skeleton = async (inputs: { }) return { items: res.items.map((item) => ({ - post: { uri: item.uri, cid: item.cid || undefined }, + post: { uri: item.uri as AtUriString, cid: item.cid || undefined }, repost: item.repost - ? { uri: item.repost, cid: item.repostCid || undefined } + ? { uri: item.repost as AtUriString, cid: item.repostCid || undefined } : undefined, })), cursor: parseString(res.cursor), @@ -124,7 +125,7 @@ const getBlocks = async (input: { params: Params }) => { const { ctx, skeleton, params } = input - const pairs: Map = new Map() + const pairs: Map = new Map() pairs.set( uriToDid(params.list), skeleton.items.map((item) => uriToDid(item.post.uri)), @@ -138,7 +139,7 @@ type Context = { dataplane: DataPlaneClient } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getListFeed.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { items: FeedItem[] diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 5b6613b72b4..e25aefa2f35 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -1,14 +1,10 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { ServerConfig } from '../../../../config' import { AppContext } from '../../../../context' import { Code, DataPlaneClient, isDataplaneError } from '../../../../data-plane' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { isNotFoundPost } from '../../../../lexicon/types/app/bsky/feed/defs' -import { - OutputSchema, - QueryParams, -} from '../../../../lexicon/types/app/bsky/feed/getPostThread' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -27,10 +23,15 @@ export default function (server: Server, ctx: AppContext) { noRules, // handled in presentation: 3p block-violating replies are turned to #blockedPost, viewer blocks turned to #notFoundPost. presentation, ) - server.app.bsky.feed.getPostThread({ + server.add(app.bsky.feed.getPostThread, { auth: ctx.authVerifier.optionalStandardOrRole, + opts: { + // @TODO remove after grace period has passed, behavior is non-standard. + // temporarily added for compat w/ previous version of xrpc-server to avoid breakage of a few specified parties. + paramsParseLoose: true, + }, handler: async ({ params, auth, req, res }) => { - const { viewer, includeTakedowns, include3pBlocks } = + const { viewer, includeTakedowns, include3pBlocks, skipViewerBlocks } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ @@ -38,9 +39,10 @@ export default function (server: Server, ctx: AppContext) { viewer, includeTakedowns, include3pBlocks, + skipViewerBlocks, }) - let result: OutputSchema + let result: app.bsky.feed.getPostThread.$OutputBody try { result = await getPostThread({ ...params, hydrateCtx }, ctx) } catch (err) { @@ -65,7 +67,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: SkeletonFnInput) => { +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { const { ctx, params } = inputs const anchor = await ctx.hydrator.resolveUri(params.uri) try { @@ -76,7 +80,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { }) return { anchor, - uris: res.uris, + uris: res.uris as AtUriString[], } } catch (err) { if (isDataplaneError(err, Code.NotFound)) { @@ -105,10 +109,10 @@ const presentation = ( ) => { const { ctx, params, skeleton, hydration } = inputs const thread = ctx.views.thread(skeleton, hydration, { - height: params.parentHeight, + height: params.parentHeight!, depth: getDepth(ctx, skeleton.anchor, params), }) - if (isNotFoundPost(thread)) { + if (app.bsky.feed.defs.notFoundPost.$isTypeOf(thread)) { // @TODO technically this could be returned as a NotFoundPost based on lexicon throw new InvalidRequestError( `Post not found: ${skeleton.anchor}`, @@ -132,11 +136,11 @@ type Context = { cfg: ServerConfig } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getPostThread.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { - anchor: string - uris: string[] + anchor: AtUriString + uris: AtUriString[] } const getDepth = (ctx: Context, anchor: string, params: Params) => { @@ -144,5 +148,5 @@ const getDepth = (ctx: Context, anchor: string, params: Params) => { if (ctx.cfg.bigThreadUris.has(anchor) && ctx.cfg.bigThreadDepth) { maxDepth = ctx.cfg.bigThreadDepth } - return maxDepth ? Math.min(maxDepth, params.depth) : params.depth + return maxDepth ? Math.min(maxDepth, params.depth!) : params.depth! } diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 6b441631054..b13764d436e 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -1,13 +1,13 @@ import { dedupeStrs, mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, HydrationState, Hydrator, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { ids } from '../../../../lexicon/lexicons' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPosts' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { uriToDid as creatorFromUri } from '../../../../util/uris' import { Views } from '../../../../views' @@ -15,15 +15,21 @@ import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getPosts = createPipeline(skeleton, hydration, noBlocks, presentation) - server.app.bsky.feed.getPosts({ + server.add(app.bsky.feed.getPosts, { auth: ctx.authVerifier.standardOptionalParameterized({ lxmCheck: (method) => { if (!method) return false return ( - method === ids.AppBskyFeedGetPosts || method.startsWith('chat.bsky.') + method === app.bsky.feed.getPosts.$lxm || + method.startsWith('chat.bsky.') ) }, }), + opts: { + // @TODO remove after grace period has passed, behavior is non-standard. + // temporarily added for compat w/ previous version of xrpc-server to avoid breakage of a few specified parties. + paramsParseLoose: true, + }, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) @@ -40,7 +46,7 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: { params: Params }) => { +const skeleton = async (inputs: { params: Params }): Promise => { return { posts: dedupeStrs(inputs.params.uris) } } @@ -87,8 +93,8 @@ type Context = { views: Views } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getPosts.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { - posts: string[] + posts: AtUriString[] } diff --git a/packages/bsky/src/api/app/bsky/feed/getQuotes.ts b/packages/bsky/src/api/app/bsky/feed/getQuotes.ts index 3f91befa08e..5dbd0aafc62 100644 --- a/packages/bsky/src/api/app/bsky/feed/getQuotes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getQuotes.ts @@ -1,4 +1,6 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, @@ -6,8 +8,7 @@ import { Hydrator, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getQuotes' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { uriToDid } from '../../../../util/uris' import { Views } from '../../../../views' @@ -20,15 +21,17 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrNeedsReview, presentation, ) - server.app.bsky.feed.getQuotes({ + server.add(app.bsky.feed.getQuotes, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getQuotes({ ...params, hydrateCtx }, ctx) return { @@ -54,7 +57,7 @@ const skeleton = async (inputs: { limit: params.limit, }) return { - uris: quotesRes.uris, + uris: quotesRes.uris as AtUriString[], cursor: parseString(quotesRes.cursor), } } @@ -63,7 +66,7 @@ const hydration = async (inputs: { ctx: Context params: Params skeleton: Skeleton -}) => { +}): Promise => { const { ctx, params, skeleton } = inputs return await ctx.hydrator.hydratePosts( skeleton.uris.map((uri) => ({ uri })), @@ -111,9 +114,9 @@ type Context = { views: Views } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getQuotes.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { - uris: string[] + uris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts index cf514bbf84b..c74733a3289 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,4 +1,6 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, @@ -6,8 +8,7 @@ import { Hydrator, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getRepostedBy' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { uriToDid as creatorFromUri } from '../../../../util/uris' import { Views } from '../../../../views' @@ -20,15 +21,17 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.feed.getRepostedBy({ + server.add(app.bsky.feed.getRepostedBy, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getRepostedBy({ ...params, hydrateCtx }, ctx) @@ -55,7 +58,7 @@ const skeleton = async (inputs: { limit: params.limit, }) return { - reposts: res.uris, + reposts: res.uris as AtUriString[], cursor: parseString(res.cursor), } } @@ -110,9 +113,9 @@ type Context = { views: Views } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.feed.getRepostedBy.$Params & { hydrateCtx: HydrateCtx } type Skeleton = { - reposts: string[] + reposts: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts index 17e91c5449c..629f62eb2f8 100644 --- a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -1,11 +1,13 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.feed.getSuggestedFeeds({ + server.add(app.bsky.feed.getSuggestedFeeds, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -17,7 +19,7 @@ export default function (server: Server, ctx: AppContext) { limit: params.limit, cursor: params.cursor, }) - const uris = suggestedRes.uris + const uris = suggestedRes.uris as AtUriString[] const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) const hydration = await ctx.hydrator.hydrateFeedGens(uris, hydrateCtx) const feedViews = mapDefined(uris, (uri) => diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 31930e9995e..ad141216a9e 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -1,15 +1,16 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { FeedItem } from '../../../../hydration/feed' import { - HydrateCtx, + HydrateCtxWithViewer, HydrationState, Hydrator, } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getTimeline' +import { app } from '../../../../lexicons/index.js' import { createPipeline } from '../../../../pipeline' import { Views } from '../../../../views' import { clearlyBadCursor, resHeaders } from '../../../util' @@ -21,17 +22,19 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrMutes, presentation, ) - server.app.bsky.feed.getTimeline({ + server.add(app.bsky.feed.getTimeline, { auth: ctx.authVerifier.standard, + opts: { + // @TODO remove after grace period has passed, behavior is non-standard. + // temporarily added for compat w/ previous version of xrpc-server to avoid breakage of a few specified parties. + paramsParseLoose: true, + }, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const result = await getTimeline( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getTimeline({ ...params, hydrateCtx }, ctx) const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer) @@ -59,9 +62,9 @@ export const skeleton = async (inputs: { }) return { items: res.items.map((item) => ({ - post: { uri: item.uri, cid: item.cid || undefined }, + post: { uri: item.uri as AtUriString, cid: item.cid || undefined }, repost: item.repost - ? { uri: item.repost, cid: item.repostCid || undefined } + ? { uri: item.repost as AtUriString, cid: item.repostCid || undefined } : undefined, })), cursor: parseString(res.cursor), @@ -114,7 +117,9 @@ type Context = { dataplane: DataPlaneClient } -type Params = QueryParams & { hydrateCtx: HydrateCtx & { viewer: string } } +type Params = app.bsky.feed.getTimeline.$Params & { + hydrateCtx: HydrateCtxWithViewer +} type Skeleton = { items: FeedItem[] diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts index 61ad6ce55b9..5193a130d4c 100644 --- a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -1,5 +1,6 @@ -import { AtpAgent } from '@atproto/api' import { mapDefined } from '@atproto/common' +import { AtUriString, Client } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { ServerConfig } from '../../../../config' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' @@ -7,11 +8,9 @@ import { PostSearchQuery, parsePostSearchQuery, } from '../../../../data-plane/server/util' -import { FeatureGateID } from '../../../../feature-gates' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/searchPosts' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -30,18 +29,22 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrTagged, presentation, ) - server.app.bsky.feed.searchPosts({ + server.add(app.bsky.feed.searchPosts, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { - const { viewer, isModService } = ctx.authVerifier.parseCreds(auth) + const { viewer, isModService, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, - featureGates: ctx.featureGates.checkGates( - [ctx.featureGates.ids.SearchFilteringExplorationEnable], - ctx.featureGates.userContext({ did: viewer }), + skipViewerBlocks, + features: ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), ), }) const results = await searchPosts( @@ -57,16 +60,19 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: SkeletonFnInput) => { +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { const { ctx, params } = inputs const parsedQuery = parsePostSearchQuery(params.q, { author: params.author, }) - if (ctx.searchAgent) { + if (ctx.searchClient) { // @NOTE cursors won't change on appview swap - const { data: res } = - await ctx.searchAgent.api.app.bsky.unspecced.searchPostsSkeleton({ + const res = await ctx.searchClient.call( + app.bsky.unspecced.searchPostsSkeleton, + { q: params.q, cursor: params.cursor, limit: params.limit, @@ -80,9 +86,10 @@ const skeleton = async (inputs: SkeletonFnInput) => { until: params.until, url: params.url, viewer: params.hydrateCtx.viewer ?? undefined, - }) + }, + ) return { - posts: res.posts.map(({ uri }) => uri), + posts: res.posts.map(({ uri }) => uri as AtUriString), cursor: parseString(res.cursor), parsedQuery, } @@ -94,7 +101,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { cursor: params.cursor, }) return { - posts: res.uris, + posts: res.uris as AtUriString[], cursor: parseString(res.cursor), parsedQuery, } @@ -109,8 +116,8 @@ const hydration = async ( params.hydrateCtx, undefined, { - processDynamicTagsForView: params.hydrateCtx.featureGates.get( - FeatureGateID.SearchFilteringExplorationEnable, + processDynamicTagsForView: params.hydrateCtx.features?.checkGate( + params.hydrateCtx.features.Gate.SearchFilteringExplorationEnable, ) ? 'search' : undefined, @@ -139,8 +146,8 @@ const noBlocksOrTagged = (inputs: RulesFnInput) => { let tagged = false if ( - params.hydrateCtx.featureGates.get( - FeatureGateID.SearchFilteringExplorationEnable, + params.hydrateCtx.features?.checkGate( + params.hydrateCtx.features.Gate.SearchFilteringExplorationEnable, ) ) { tagged = post.tags.has(ctx.cfg.visibilityTagHide) @@ -178,16 +185,16 @@ type Context = { dataplane: DataPlaneClient hydrator: Hydrator views: Views - searchAgent?: AtpAgent + searchClient?: Client } -type Params = QueryParams & { +type Params = app.bsky.feed.searchPosts.$Params & { hydrateCtx: HydrateCtx isModService: boolean } type Skeleton = { - posts: string[] + posts: AtUriString[] hitsTotal?: number cursor?: string parsedQuery: PostSearchQuery diff --git a/packages/bsky/src/api/app/bsky/graph/getActorStarterPacks.ts b/packages/bsky/src/api/app/bsky/graph/getActorStarterPacks.ts index 744297b9726..6c49cb68fd5 100644 --- a/packages/bsky/src/api/app/bsky/graph/getActorStarterPacks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getActorStarterPacks.ts @@ -1,11 +1,11 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getActorStarterPacks' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -23,15 +23,17 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.graph.getActorStarterPacks({ + server.add(app.bsky.graph.getActorStarterPacks, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getActorStarterPacks({ ...params, hydrateCtx }, ctx) return { @@ -57,7 +59,7 @@ const skeleton = async ( limit: params.limit, }) return { - starterPackUris: starterPacks.uris, + starterPackUris: starterPacks.uris as AtUriString[], cursor: parseString(starterPacks.cursor), } } @@ -91,9 +93,11 @@ type Context = { dataplane: DataPlaneClient } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.graph.getActorStarterPacks.$Params & { + hydrateCtx: HydrateCtx +} type SkeletonState = { - starterPackUris: string[] + starterPackUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts index bed8610babd..5931565a88d 100644 --- a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts @@ -1,8 +1,9 @@ import { mapDefined } from '@atproto/common' +import { AtUriString, DidString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getBlocks' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -15,16 +16,13 @@ import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getBlocks = createPipeline(skeleton, hydration, noRules, presentation) - server.app.bsky.graph.getBlocks({ + server.add(app.bsky.graph.getBlocks, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const result = await getBlocks( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getBlocks({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, @@ -34,16 +32,21 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input if (clearlyBadCursor(params.cursor)) { return { blockedDids: [] } } - const { blockUris, cursor } = await ctx.hydrator.dataplane.getBlocks({ + const { blockUris, cursor } = (await ctx.hydrator.dataplane.getBlocks({ actorDid: params.hydrateCtx.viewer, cursor: params.cursor, limit: params.limit, - }) + })) as { + blockUris: AtUriString[] + cursor?: string + } const blocks = await ctx.hydrator.graph.getBlocks(blockUris) const blockedDids = mapDefined( blockUris, @@ -78,11 +81,11 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getBlocks.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - blockedDids: string[] + blockedDids: DidString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index 12aff0eefb2..12dc3009666 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -1,13 +1,13 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString, DidString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator, mergeStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollowers' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -26,15 +26,17 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.graph.getFollowers({ + server.add(app.bsky.graph.getFollowers, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getFollowers({ ...params, hydrateCtx }, ctx) @@ -48,7 +50,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]) if (!subjectDid) { @@ -64,7 +68,7 @@ const skeleton = async (input: SkeletonFnInput) => { }) return { subjectDid, - followUris: followers.map((f) => f.uri), + followUris: followers.map((f) => f.uri as AtUriString), cursor: cursor || undefined, } } @@ -111,7 +115,8 @@ const presentation = ( ) => { const { ctx, hydration, skeleton, params } = input const { subjectDid, followUris, cursor } = skeleton - const isNoHosted = (did: string) => ctx.views.actorIsNoHosted(did, hydration) + const isNoHosted = (did: DidString) => + ctx.views.actorIsNoHosted(did, hydration) const subject = ctx.views.profile(subjectDid, hydration) if ( @@ -137,12 +142,12 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.graph.getFollowers.$Params & { hydrateCtx: HydrateCtx } type SkeletonState = { - subjectDid: string - followUris: string[] + subjectDid: DidString + followUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index 0791aa66a85..d70b0c64ad6 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -1,13 +1,13 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString, DidString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator, mergeStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollowers' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -20,15 +20,17 @@ import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getFollows = createPipeline(skeleton, hydration, noBlocks, presentation) - server.app.bsky.graph.getFollows({ + server.add(app.bsky.graph.getFollows, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) // @TODO ensure canViewTakedowns gets threaded through and applied properly @@ -43,7 +45,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]) if (!subjectDid) { @@ -59,7 +63,7 @@ const skeleton = async (input: SkeletonFnInput) => { }) return { subjectDid, - followUris: follows.map((f) => f.uri), + followUris: follows.map((f) => f.uri as AtUriString), cursor: cursor || undefined, } } @@ -108,7 +112,8 @@ const presentation = ( ) => { const { ctx, hydration, skeleton, params } = input const { subjectDid, followUris, cursor } = skeleton - const isNoHosted = (did: string) => ctx.views.actorIsNoHosted(did, hydration) + const isNoHosted = (did: DidString) => + ctx.views.actorIsNoHosted(did, hydration) const subject = ctx.views.profile(subjectDid, hydration) if ( @@ -135,12 +140,12 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.graph.getFollowers.$Params & { hydrateCtx: HydrateCtx } type SkeletonState = { - subjectDid: string - followUris: string[] + subjectDid: DidString + followUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getKnownFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getKnownFollowers.ts index b314e0a1dad..6ce336665d4 100644 --- a/packages/bsky/src/api/app/bsky/graph/getKnownFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getKnownFollowers.ts @@ -1,9 +1,9 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { DidString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getKnownFollowers' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -21,7 +21,7 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.graph.getKnownFollowers({ + server.add(app.bsky.graph.getKnownFollowers, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -31,10 +31,7 @@ export default function (server: Server, ctx: AppContext) { viewer, }) - const result = await getKnownFollowers( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getKnownFollowers({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', @@ -45,7 +42,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input const [subjectDid] = await ctx.hydrator.actor.getDidsDefined([params.actor]) if (!subjectDid) { @@ -60,7 +59,9 @@ const skeleton = async (input: SkeletonFnInput) => { targetDids: [subjectDid], }) const result = res.results.at(0) - const knownFollowers = result ? result.dids.slice(0, params.limit) : [] + const knownFollowers = result + ? (result.dids.slice(0, params.limit) as DidString[]) + : [] return { subjectDid, @@ -108,12 +109,12 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getKnownFollowers.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - subjectDid: string - knownFollowers: string[] + subjectDid: DidString + knownFollowers: DidString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index d9a4f90cd6b..45fc4e26b2d 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -1,5 +1,6 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString, DidString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, @@ -7,8 +8,7 @@ import { Hydrator, mergeManyStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getList' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -23,16 +23,18 @@ import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getList = createPipeline(skeleton, hydration, noBlocks, presentation) - server.app.bsky.graph.getList({ + server.add(app.bsky.graph.getList, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getList({ ...params, hydrateCtx }, ctx) return { @@ -71,7 +73,7 @@ const hydration = async ( const [listState, profileState] = await Promise.all([ ctx.hydrator.hydrateLists([listUri], params.hydrateCtx), ctx.hydrator.hydrateProfiles( - listitems.map(({ did }) => did), + listitems.map(({ did }) => did as DidString), params.hydrateCtx, ), ]) @@ -89,7 +91,7 @@ const noBlocks = (input: RulesFnInput) => { const creator = didFromUri(skeleton.listUri) const blocks = hydration.bidirectionalBlocks?.get(creator) skeleton.listitems = skeleton.listitems.filter(({ did }) => { - return !blocks?.get(did) + return !blocks?.get(did as DidString) }) return skeleton } @@ -101,7 +103,7 @@ const presentation = ( const { listUri, listitems, cursor } = skeleton const list = ctx.views.list(listUri, hydration) const items = mapDefined(listitems, ({ uri, did }) => - ctx.views.listItemView(uri, did, hydration), + ctx.views.listItemView(uri as AtUriString, did as DidString, hydration), ) if (!list) { throw new InvalidRequestError('List not found') @@ -126,10 +128,10 @@ const maybeGetBlocksForReferenceAndCurateList = async (input: { ) { return } - const pairs: Map = new Map() + const pairs: Map = new Map() pairs.set( creator, - listitems.map(({ did }) => did), + listitems.map(({ did }) => did as DidString), ) return await ctx.hydrator.hydrateBidirectionalBlocks(pairs, params.hydrateCtx) } @@ -139,12 +141,12 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.graph.getList.$Params & { hydrateCtx: HydrateCtx } type SkeletonState = { - listUri: string + listUri: AtUriString listitems: ListItemInfo[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts index 70fd3c9b4a2..4bc49dd71be 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts @@ -1,8 +1,9 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListBlocks' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -20,16 +21,13 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.graph.getListBlocks({ + server.add(app.bsky.graph.getListBlocks, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const result = await getListBlocks( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getListBlocks({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, @@ -52,7 +50,7 @@ const skeleton = async ( cursor: params.cursor, limit: params.limit, }) - return { listUris, cursor: cursor || undefined } + return { listUris: listUris as AtUriString[], cursor: cursor || undefined } } const hydration = async ( @@ -76,11 +74,11 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getListBlocks.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - listUris: string[] + listUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts index 61f951d06d2..3411a129bd4 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts @@ -1,8 +1,9 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListBlocks' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -20,16 +21,13 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.graph.getListMutes({ + server.add(app.bsky.graph.getListMutes, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const result = await getListMutes( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getListMutes({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, @@ -52,7 +50,7 @@ const skeleton = async ( cursor: params.cursor, limit: params.limit, }) - return { listUris, cursor: cursor || undefined } + return { listUris: listUris as AtUriString[], cursor: cursor || undefined } } const hydration = async ( @@ -76,11 +74,11 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getListBlocks.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - listUris: string[] + listUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getLists.ts b/packages/bsky/src/api/app/bsky/graph/getLists.ts index d5e00f7b3be..bf2eddecb02 100644 --- a/packages/bsky/src/api/app/bsky/graph/getLists.ts +++ b/packages/bsky/src/api/app/bsky/graph/getLists.ts @@ -1,13 +1,10 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { - CURATELIST, - MODLIST, -} from '../../../../lexicon/types/app/bsky/graph/defs' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getLists' +import { parseString } from '../../../../hydration/util' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -18,6 +15,9 @@ import { import { Views } from '../../../../views' import { clearlyBadCursor, resHeaders } from '../../../util' +const CURATELIST = app.bsky.graph.defs.curatelist.value +const MODLIST = app.bsky.graph.defs.modlist.value + export default function (server: Server, ctx: AppContext) { const getLists = createPipeline( skeleton, @@ -25,15 +25,17 @@ export default function (server: Server, ctx: AppContext) { filterPurposes, presentation, ) - server.app.bsky.graph.getLists({ + server.add(app.bsky.graph.getLists, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { const labelers = ctx.reqLabelers(req) - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getLists({ ...params, hydrateCtx }, ctx) @@ -62,7 +64,10 @@ const skeleton = async ( cursor: params.cursor, limit: params.limit, }) - return { listUris, cursor: cursor || undefined } + return { + listUris: listUris as AtUriString[], + cursor: parseString(cursor), + } } const hydration = async ( @@ -110,11 +115,11 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.graph.getLists.$Params & { hydrateCtx: HydrateCtx } type SkeletonState = { - listUris: string[] + listUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getListsWithMembership.ts b/packages/bsky/src/api/app/bsky/graph/getListsWithMembership.ts index 1ca61703fe9..7918e6a8b2f 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListsWithMembership.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListsWithMembership.ts @@ -1,13 +1,10 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString, DidString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { - CURATELIST, - MODLIST, -} from '../../../../lexicon/types/app/bsky/graph/defs' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListsWithMembership' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { parseString } from '../../../../hydration/util' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -18,6 +15,9 @@ import { import { Views } from '../../../../views' import { clearlyBadCursor, resHeaders } from '../../../util' +const CURATELIST = app.bsky.graph.defs.curatelist.value +const MODLIST = app.bsky.graph.defs.modlist.value + export default function (server: Server, ctx: AppContext) { const getListsWithMembership = createPipeline( skeleton, @@ -25,7 +25,7 @@ export default function (server: Server, ctx: AppContext) { filterPurposes, presentation, ) - server.app.bsky.graph.getListsWithMembership({ + server.add(app.bsky.graph.getListsWithMembership, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -35,7 +35,7 @@ export default function (server: Server, ctx: AppContext) { viewer, }) const result = await getListsWithMembership( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + { ...params, hydrateCtx }, ctx, ) @@ -64,7 +64,11 @@ const skeleton = async ( cursor: params.cursor, limit: params.limit, }) - return { actorDid, listUris, cursor: cursor || undefined } + return { + actorDid, + listUris: listUris as AtUriString[], + cursor: parseString(cursor), + } } const hydration = async ( @@ -128,12 +132,12 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getListsWithMembership.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - actorDid: string - listUris: string[] + actorDid: DidString + listUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getMutes.ts b/packages/bsky/src/api/app/bsky/graph/getMutes.ts index c28671ad716..dfab470af83 100644 --- a/packages/bsky/src/api/app/bsky/graph/getMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getMutes.ts @@ -1,8 +1,9 @@ import { mapDefined } from '@atproto/common' +import { DidString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getMutes' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -15,16 +16,13 @@ import { clearlyBadCursor, resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { const getMutes = createPipeline(skeleton, hydration, noRules, presentation) - server.app.bsky.graph.getMutes({ + server.add(app.bsky.graph.getMutes, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const result = await getMutes( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await getMutes({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, @@ -34,7 +32,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input if (clearlyBadCursor(params.cursor)) { return { mutedDids: [] } @@ -45,7 +45,7 @@ const skeleton = async (input: SkeletonFnInput) => { limit: params.limit, }) return { - mutedDids: dids, + mutedDids: dids as DidString[], cursor: cursor || undefined, } } @@ -74,11 +74,11 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getMutes.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - mutedDids: string[] + mutedDids: DidString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getRelationships.ts b/packages/bsky/src/api/app/bsky/graph/getRelationships.ts index a5db55cae03..947d3c06cef 100644 --- a/packages/bsky/src/api/app/bsky/graph/getRelationships.ts +++ b/packages/bsky/src/api/app/bsky/graph/getRelationships.ts @@ -1,42 +1,51 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.app.bsky.graph.getRelationships({ + server.add(app.bsky.graph.getRelationships, { handler: async ({ params }) => { - const { actor, others = [] } = params - if (others.length < 1) { + const { others = [] } = params + + const [actor] = await ctx.hydrator.actor.getDids([params.actor]) + if (!actor || others.length < 1) { return { encoding: 'application/json', body: { actor, - relationships: [], + relationships: others.map((actor) => { + return app.bsky.graph.defs.notFoundActor.$build({ + actor, + notFound: true, + }) + }), }, } } + const res = await ctx.hydrator.actor.getProfileViewerStatesNaive( others, actor, ) - const relationships = others.map((did) => { - const subject = res.get(did) + + const relationships = others.map((actor) => { + const subject = res.get(actor) return subject - ? { - $type: 'app.bsky.graph.defs#relationship', - did, + ? app.bsky.graph.defs.relationship.$build({ + did: subject.did, following: subject.following, followedBy: subject.followedBy, blocking: subject.blocking, blockedBy: subject.blockedBy, blockingByList: subject.blockingByList, blockedByList: subject.blockedByList, - } - : { - $type: 'app.bsky.graph.defs#notFoundActor', - actor: did, + }) + : app.bsky.graph.defs.notFoundActor.$build({ + actor, notFound: true, - } + }) }) + return { encoding: 'application/json', body: { diff --git a/packages/bsky/src/api/app/bsky/graph/getStarterPack.ts b/packages/bsky/src/api/app/bsky/graph/getStarterPack.ts index 8832bfd5f38..72edb5a7bda 100644 --- a/packages/bsky/src/api/app/bsky/graph/getStarterPack.ts +++ b/packages/bsky/src/api/app/bsky/graph/getStarterPack.ts @@ -1,8 +1,8 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getStarterPack' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -20,15 +20,17 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.graph.getStarterPack({ + server.add(app.bsky.graph.getStarterPack, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, + skipViewerBlocks, }) const result = await getStarterPack({ ...params, hydrateCtx }, ctx) return { @@ -71,10 +73,10 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.graph.getStarterPack.$Params & { hydrateCtx: HydrateCtx } type SkeletonState = { - uri: string + uri: AtUriString } diff --git a/packages/bsky/src/api/app/bsky/graph/getStarterPacks.ts b/packages/bsky/src/api/app/bsky/graph/getStarterPacks.ts index dac4baf0315..1232eedb588 100644 --- a/packages/bsky/src/api/app/bsky/graph/getStarterPacks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getStarterPacks.ts @@ -1,12 +1,13 @@ import { dedupeStrs, mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, HydrationState, Hydrator, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getStarterPacks' +import { app } from '../../../../lexicons/index.js' import { createPipeline, noRules } from '../../../../pipeline' import { Views } from '../../../../views' import { resHeaders } from '../../../util' @@ -18,15 +19,17 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.graph.getStarterPacks({ + server.add(app.bsky.graph.getStarterPacks, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers, includeTakedowns, + skipViewerBlocks, }) const result = await getStarterPacks({ ...params, hydrateCtx }, ctx) @@ -74,8 +77,10 @@ type Context = { views: Views } -type Params = QueryParams & { +type Params = app.bsky.graph.getStarterPacks.$Params & { hydrateCtx: HydrateCtx } -type SkeletonState = { uris: string[] } +type SkeletonState = { + uris: AtUriString[] +} diff --git a/packages/bsky/src/api/app/bsky/graph/getStarterPacksWithMembership.ts b/packages/bsky/src/api/app/bsky/graph/getStarterPacksWithMembership.ts index 9da264078d7..2a39813fa21 100644 --- a/packages/bsky/src/api/app/bsky/graph/getStarterPacksWithMembership.ts +++ b/packages/bsky/src/api/app/bsky/graph/getStarterPacksWithMembership.ts @@ -1,16 +1,13 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString, DidString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { - HydrateCtx, + HydrateCtxWithViewer, Hydrator, mergeManyStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { - OutputSchema, - QueryParams, -} from '../../../../lexicon/types/app/bsky/graph/getStarterPacksWithMembership' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -28,7 +25,7 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.graph.getStarterPacksWithMembership({ + server.add(app.bsky.graph.getStarterPacksWithMembership, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -38,7 +35,7 @@ export default function (server: Server, ctx: AppContext) { viewer, }) const result = await getStarterPacksWithMembership( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + { ...params, hydrateCtx }, ctx, ) @@ -69,7 +66,11 @@ const skeleton = async ( limit: params.limit, }) - return { actorDid, starterPackUris, cursor: cursor || undefined } + return { + actorDid, + starterPackUris: starterPackUris as AtUriString[], + cursor: cursor || undefined, + } } const hydration = async ( @@ -96,7 +97,7 @@ const hydration = async ( const presentation = ( input: PresentationFnInput, -): OutputSchema => { +): app.bsky.graph.getStarterPacksWithMembership.$OutputBody => { const { ctx, skeleton, hydration } = input const { actorDid, starterPackUris, cursor } = skeleton @@ -124,12 +125,12 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getStarterPacksWithMembership.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - actorDid: string - starterPackUris: string[] + actorDid: DidString + starterPackUris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index b6d109b30dc..ae7e011bb83 100644 --- a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -1,11 +1,14 @@ -import { AtpAgent } from '@atproto/api' import { mapDefined, noUndefinedVals } from '@atproto/common' -import { HeadersMap } from '@atproto/xrpc' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { Client, DidString } from '@atproto/lex' +import { + Headers as HeadersMap, + InternalServerError, + InvalidRequestError, + Server, +} from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getSuggestedFollowsByActor' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -23,31 +26,47 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrMutes, presentation, ) - server.app.bsky.graph.getSuggestedFollowsByActor({ - auth: ctx.authVerifier.standard, + server.add(app.bsky.graph.getSuggestedFollowsByActor, { + auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) - const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + features: ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), + ), + }) + + if (!ctx.suggestionsClient) { + return { + encoding: 'application/json', + body: { suggestions: [] }, + headers: resHeaders({ labelers: hydrateCtx.labelers }), + } + } + const headers = noUndefinedVals({ 'accept-language': req.headers['accept-language'], 'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics']) ? req.headers['x-bsky-topics'].join(',') : req.headers['x-bsky-topics'], }) - const { headers: resultHeaders, ...result } = - await getSuggestedFollowsByActor( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }), headers }, - ctx, - ) - const responseHeaders = noUndefinedVals({ - 'content-language': resultHeaders?.['content-language'], - }) + + const { contentLanguage, ...body } = await getSuggestedFollowsByActor( + { ...params, hydrateCtx, headers }, + ctx, + ) + return { encoding: 'application/json', - body: result, + body, headers: { - ...responseHeaders, + ...(contentLanguage ? { 'content-language': contentLanguage } : null), ...resHeaders({ labelers: hydrateCtx.labelers }), }, } @@ -55,37 +74,36 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input + + // handled above already, this branch should not be reached + if (!ctx.suggestionsClient) { + throw new InternalServerError('Suggestions service not configured') + } + const [relativeToDid] = await ctx.hydrator.actor.getDids([params.actor]) if (!relativeToDid) { throw new InvalidRequestError('Actor not found') } - if (ctx.suggestionsAgent) { - const res = - await ctx.suggestionsAgent.api.app.bsky.unspecced.getSuggestionsSkeleton( - { - viewer: params.hydrateCtx.viewer ?? undefined, - relativeToDid, - }, - { headers: params.headers }, - ) - return { - isFallback: !res.data.relativeToDid, - suggestedDids: res.data.actors.map((a) => a.did), - recId: res.data.recId, - headers: res.headers, - } - } else { - const { dids } = await ctx.hydrator.dataplane.getFollowSuggestions({ - actorDid: params.hydrateCtx.viewer, - relativeToDid, - }) - return { - isFallback: true, - suggestedDids: dids, - } + const res = await ctx.suggestionsClient.xrpc( + app.bsky.unspecced.getSuggestionsSkeleton, + { + params: { + viewer: params.hydrateCtx.viewer ?? undefined, + relativeToDid, + }, + headers: params.headers, + }, + ) + + return { + recIdStr: res.body.recIdStr, + suggestedDids: res.body.actors.map((a) => a.did), + contentLanguage: res.headers.get('content-language') ?? undefined, } } @@ -94,7 +112,18 @@ const hydration = async ( ) => { const { ctx, params, skeleton } = input const { suggestedDids } = skeleton - return ctx.hydrator.hydrateProfiles(suggestedDids, params.hydrateCtx) + if ( + params.hydrateCtx.features.checkGate( + params.hydrateCtx.features.Gate.SuggestedUsersSocialProofEnable, + ) + ) { + return ctx.hydrator.hydrateProfilesDetailed( + suggestedDids, + params.hydrateCtx, + ) + } else { + return ctx.hydrator.hydrateProfiles(suggestedDids, params.hydrateCtx) + } } const noBlocksOrMutes = ( @@ -113,33 +142,30 @@ const presentation = ( input: PresentationFnInput, ) => { const { ctx, hydration, skeleton } = input - const { suggestedDids, headers } = skeleton + const { suggestedDids, contentLanguage } = skeleton const suggestions = mapDefined(suggestedDids, (did) => - ctx.views.profile(did, hydration), + ctx.views.profileKnownFollowers(did, hydration), ) return { - isFallback: skeleton.isFallback, + recIdStr: skeleton.recIdStr, + contentLanguage, suggestions, - recId: skeleton.recId, - headers, } } type Context = { hydrator: Hydrator views: Views - suggestionsAgent: AtpAgent | undefined - featureGates: AppContext['featureGates'] + suggestionsClient: Client | undefined } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.graph.getSuggestedFollowsByActor.$Params & { + hydrateCtx: HydrateCtx headers: HeadersMap } type SkeletonState = { - isFallback: boolean - suggestedDids: string[] - recId?: number - headers?: HeadersMap + suggestedDids: DidString[] + recIdStr?: string + contentLanguage?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/muteActor.ts b/packages/bsky/src/api/app/bsky/graph/muteActor.ts index 2f25af7966e..94f0d87abbc 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteActor.ts @@ -1,10 +1,10 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { MuteOperation_Type } from '../../../../proto/bsync_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.graph.muteActor({ + server.add(app.bsky.graph.muteActor, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { const { actor } = input.body diff --git a/packages/bsky/src/api/app/bsky/graph/muteActorList.ts b/packages/bsky/src/api/app/bsky/graph/muteActorList.ts index 177e6a7c21f..d8b963c6040 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteActorList.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteActorList.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { MuteOperation_Type } from '../../../../proto/bsync_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.graph.muteActorList({ + server.add(app.bsky.graph.muteActorList, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { const { list } = input.body diff --git a/packages/bsky/src/api/app/bsky/graph/muteThread.ts b/packages/bsky/src/api/app/bsky/graph/muteThread.ts index 013a6772519..7477e003b5f 100644 --- a/packages/bsky/src/api/app/bsky/graph/muteThread.ts +++ b/packages/bsky/src/api/app/bsky/graph/muteThread.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { MuteOperation_Type } from '../../../../proto/bsync_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.graph.muteThread({ + server.add(app.bsky.graph.muteThread, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { const { root } = input.body diff --git a/packages/bsky/src/api/app/bsky/graph/searchStarterPacks.ts b/packages/bsky/src/api/app/bsky/graph/searchStarterPacks.ts index 5665950a63c..07cff23dc6e 100644 --- a/packages/bsky/src/api/app/bsky/graph/searchStarterPacks.ts +++ b/packages/bsky/src/api/app/bsky/graph/searchStarterPacks.ts @@ -1,11 +1,11 @@ -import { AtpAgent } from '@atproto/api' import { mapDefined } from '@atproto/common' +import { AtUriString, Client } from '@atproto/lex' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { DataPlaneClient } from '../../../../data-plane' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/searchStarterPacks' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -24,15 +24,17 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.graph.searchStarterPacks({ + server.add(app.bsky.graph.searchStarterPacks, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { - const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth) + const { viewer, includeTakedowns, skipViewerBlocks } = + ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ viewer, labelers, includeTakedowns, + skipViewerBlocks, }) const results = await searchStarterPacks({ ...params, hydrateCtx }, ctx) return { @@ -44,19 +46,23 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: SkeletonFnInput) => { +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { const { ctx, params } = inputs const { q } = params - if (ctx.searchAgent) { + if (ctx.searchClient) { // @NOTE cursors won't change on appview swap - const { data: res } = - await ctx.searchAgent.app.bsky.unspecced.searchStarterPacksSkeleton({ + const res = await ctx.searchClient.call( + app.bsky.unspecced.searchStarterPacksSkeleton, + { q, cursor: params.cursor, limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined, - }) + }, + ) return { uris: res.starterPacks.map(({ uri }) => uri), cursor: parseString(res.cursor), @@ -69,7 +75,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { cursor: params.cursor, }) return { - uris: res.uris, + uris: res.uris as AtUriString[], cursor: parseString(res.cursor), } } @@ -107,12 +113,14 @@ type Context = { dataplane: DataPlaneClient hydrator: Hydrator views: Views - searchAgent?: AtpAgent + searchClient?: Client } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.graph.searchStarterPacks.$Params & { + hydrateCtx: HydrateCtx +} type Skeleton = { - uris: string[] + uris: AtUriString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts b/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts index 393da722d5c..81a6dd91fde 100644 --- a/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/unmuteActor.ts @@ -1,10 +1,10 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { MuteOperation_Type } from '../../../../proto/bsync_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.graph.unmuteActor({ + server.add(app.bsky.graph.unmuteActor, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { const { actor } = input.body diff --git a/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts b/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts index 8ebd4c7006c..455f7a1961d 100644 --- a/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts +++ b/packages/bsky/src/api/app/bsky/graph/unmuteActorList.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { MuteOperation_Type } from '../../../../proto/bsync_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.graph.unmuteActorList({ + server.add(app.bsky.graph.unmuteActorList, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { const { list } = input.body diff --git a/packages/bsky/src/api/app/bsky/graph/unmuteThread.ts b/packages/bsky/src/api/app/bsky/graph/unmuteThread.ts index 55507633a38..3cac0821be8 100644 --- a/packages/bsky/src/api/app/bsky/graph/unmuteThread.ts +++ b/packages/bsky/src/api/app/bsky/graph/unmuteThread.ts @@ -1,9 +1,10 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { MuteOperation_Type } from '../../../../proto/bsync_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.graph.unmuteThread({ + server.add(app.bsky.graph.unmuteThread, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { const { root } = input.body diff --git a/packages/bsky/src/api/app/bsky/labeler/getServices.ts b/packages/bsky/src/api/app/bsky/labeler/getServices.ts index df98a862234..6d2be4455ed 100644 --- a/packages/bsky/src/api/app/bsky/labeler/getServices.ts +++ b/packages/bsky/src/api/app/bsky/labeler/getServices.ts @@ -1,10 +1,11 @@ import { mapDefined } from '@atproto/common' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.labeler.getServices({ + server.add(app.bsky.labeler.getServices, { auth: ctx.authVerifier.standardOptional, handler: async ({ params, auth, req }) => { const { dids, detailed } = params @@ -20,17 +21,11 @@ export default function (server: Server, ctx: AppContext) { if (detailed) { const view = ctx.views.labelerDetailed(did, hydration) if (!view) return - return { - ...view, - $type: 'app.bsky.labeler.defs#labelerViewDetailed', - } + return app.bsky.labeler.defs.labelerViewDetailed.$build(view) } else { const view = ctx.views.labeler(did, hydration) if (!view) return - return { - ...view, - $type: 'app.bsky.labeler.defs#labelerView', - } + return app.bsky.labeler.defs.labelerView.$build(view) } }) diff --git a/packages/bsky/src/api/app/bsky/notification/getPreferences.ts b/packages/bsky/src/api/app/bsky/notification/getPreferences.ts index edc3d67e616..d0013bbae87 100644 --- a/packages/bsky/src/api/app/bsky/notification/getPreferences.ts +++ b/packages/bsky/src/api/app/bsky/notification/getPreferences.ts @@ -1,14 +1,13 @@ import assert from 'node:assert' -import { Un$Typed } from '@atproto/api' -import { UpstreamFailureError } from '@atproto/xrpc-server' +import { Un$Typed } from '@atproto/lex' +import { Server, UpstreamFailureError } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { Preferences } from '../../../../lexicon/types/app/bsky/notification/defs' +import { app } from '../../../../lexicons/index.js' import { GetNotificationPreferencesResponse } from '../../../../proto/bsky_pb' import { protobufToLex } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.notification.getPreferences({ + server.add(app.bsky.notification.getPreferences, { auth: ctx.authVerifier.standard, handler: async ({ auth }) => { const actorDid = auth.credentials.iss @@ -26,7 +25,7 @@ export default function (server: Server, ctx: AppContext) { const computePreferences = async ( ctx: AppContext, actorDid: string, -): Promise> => { +): Promise> => { let res: GetNotificationPreferencesResponse try { res = await ctx.dataplane.getNotificationPreferences({ diff --git a/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts b/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts index bdb1a64c152..4455361aaf4 100644 --- a/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts +++ b/packages/bsky/src/api/app/bsky/notification/getUnreadCount.ts @@ -1,8 +1,8 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { DidString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/getUnreadCount' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -19,7 +19,7 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ) - server.app.bsky.notification.getUnreadCount({ + server.add(app.bsky.notification.getUnreadCount, { auth: ctx.authVerifier.standard, handler: async ({ auth, params }) => { const viewer = auth.credentials.iss @@ -67,15 +67,15 @@ type Context = { views: Views } -type Params = QueryParams & { - viewer: string +type Params = app.bsky.notification.getUnreadCount.$Params & { + viewer: DidString } type SkeletonState = { count: number } -const getPriority = async (ctx: Context, did: string) => { +const getPriority = async (ctx: Context, did: DidString) => { const actors = await ctx.hydrator.actor.getActors([did], { skipCacheForDids: [did], }) diff --git a/packages/bsky/src/api/app/bsky/notification/listActivitySubscriptions.ts b/packages/bsky/src/api/app/bsky/notification/listActivitySubscriptions.ts index 255d4a3d861..86ce60c523b 100644 --- a/packages/bsky/src/api/app/bsky/notification/listActivitySubscriptions.ts +++ b/packages/bsky/src/api/app/bsky/notification/listActivitySubscriptions.ts @@ -1,8 +1,9 @@ import { mapDefined } from '@atproto/common' +import { DidString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/listActivitySubscriptions' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -20,7 +21,7 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.notification.listActivitySubscriptions({ + server.add(app.bsky.notification.listActivitySubscriptions, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss @@ -31,7 +32,7 @@ export default function (server: Server, ctx: AppContext) { }) const result = await listActivitySubscriptions( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, + { ...params, hydrateCtx }, ctx, ) @@ -44,7 +45,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input const actorDid = params.hydrateCtx.viewer if (clearlyBadCursor(params.cursor)) { @@ -58,7 +61,7 @@ const skeleton = async (input: SkeletonFnInput) => { }) return { actorDid, - dids, + dids: dids as DidString[], cursor: cursor || undefined, } } @@ -99,12 +102,12 @@ type Context = { views: Views } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.notification.listActivitySubscriptions.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { - actorDid: string - dids: string[] + actorDid: DidString + dids: DidString[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index aa2ba6cf983..93609270f9d 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -1,11 +1,10 @@ import { mapDefined } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { AtUriString, DatetimeString, DidString } from '@atproto/syntax' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { ServerConfig } from '../../../../config' import { AppContext } from '../../../../context' -import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { isRecord as isPostRecord } from '../../../../lexicon/types/app/bsky/feed/post' -import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/listNotifications' +import { HydrateCtxWithViewer, Hydrator } from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -16,6 +15,7 @@ import { import { Notification } from '../../../../proto/bsky_pb' import { uriToDid as didFromUri } from '../../../../util/uris' import { Views } from '../../../../views' +import { isPostRecordType } from '../../../../views/types' import { resHeaders } from '../../../util' export default function (server: Server, ctx: AppContext) { @@ -25,16 +25,13 @@ export default function (server: Server, ctx: AppContext) { noBlockOrMutesOrNeedsFiltering, presentation, ) - server.app.bsky.notification.listNotifications({ + server.add(app.bsky.notification.listNotifications, { auth: ctx.authVerifier.standard, handler: async ({ params, auth, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) - const result = await listNotifications( - { ...params, hydrateCtx: hydrateCtx.copy({ viewer }) }, - ctx, - ) + const result = await listNotifications({ ...params, hydrateCtx }, ctx) return { encoding: 'application/json', body: result, @@ -150,7 +147,9 @@ const skeleton = async ( notifs: res.notifications, cursor: res.cursor || undefined, priority, - lastSeenNotifs: lastSeenDate?.toISOString(), + lastSeenNotifs: lastSeenDate + ? (lastSeenDate.toISOString() as DatetimeString) + : undefined, } } @@ -166,7 +165,8 @@ const noBlockOrMutesOrNeedsFiltering = ( ) => { const { skeleton, hydration, ctx, params } = input skeleton.notifs = skeleton.notifs.filter((item) => { - const did = didFromUri(item.uri) + const uri = item.uri as AtUriString + const did = didFromUri(uri) if ( ctx.views.viewerBlockExists(did, hydration) || ctx.views.viewerMuteExists(did, hydration) @@ -176,19 +176,15 @@ const noBlockOrMutesOrNeedsFiltering = ( // Filter out hidden replies only if the viewer owns // the threadgate and they hid the reply. if (item.reason === 'reply') { - const post = hydration.posts?.get(item.uri) + const post = hydration.posts?.get(uri) if (post) { - const rootPostUri = isPostRecord(post.record) + const rootPostUri = isPostRecordType(post.record) ? post.record.reply?.root.uri : undefined const isRootPostByViewer = rootPostUri && didFromUri(rootPostUri) === params.hydrateCtx?.viewer const isHiddenByThreadgate = isRootPostByViewer - ? ctx.views.replyIsHiddenByThreadgate( - item.uri, - rootPostUri, - hydration, - ) + ? ctx.views.replyIsHiddenByThreadgate(uri, rootPostUri, hydration) : false if (isHiddenByThreadgate) { return false @@ -202,7 +198,7 @@ const noBlockOrMutesOrNeedsFiltering = ( item.reason === 'quote' || item.reason === 'mention' ) { - const post = hydration.posts?.get(item.uri) + const post = hydration.posts?.get(uri) if (post) { for (const [tag] of post.tags.entries()) { if (ctx.cfg.threadTagsHide.has(tag)) { @@ -223,7 +219,7 @@ const noBlockOrMutesOrNeedsFiltering = ( item.reason === 'like' || item.reason === 'follow' ) { - if (!ctx.views.viewerSeesNeedsReview({ did, uri: item.uri }, hydration)) { + if (!ctx.views.viewerSeesNeedsReview({ did, uri }, hydration)) { return false } } @@ -234,7 +230,7 @@ const noBlockOrMutesOrNeedsFiltering = ( const presentation = ( input: PresentationFnInput, -) => { +): app.bsky.notification.listNotifications.$OutputBody => { const { skeleton, hydration, ctx } = input const { notifs, lastSeenNotifs, cursor } = skeleton const notifications = mapDefined(notifs, (notif) => @@ -254,18 +250,18 @@ type Context = { cfg: ServerConfig } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string } +type Params = app.bsky.notification.listNotifications.$Params & { + hydrateCtx: HydrateCtxWithViewer } type SkeletonState = { notifs: Notification[] priority: boolean - lastSeenNotifs?: string + lastSeenNotifs?: DatetimeString cursor?: string } -const getPriority = async (ctx: Context, did: string) => { +const getPriority = async (ctx: Context, did: DidString) => { const actors = await ctx.hydrator.actor.getActors([did], { skipCacheForDids: [did], }) diff --git a/packages/bsky/src/api/app/bsky/notification/putActivitySubscription.ts b/packages/bsky/src/api/app/bsky/notification/putActivitySubscription.ts index 3b726f2ec98..6407c930c36 100644 --- a/packages/bsky/src/api/app/bsky/notification/putActivitySubscription.ts +++ b/packages/bsky/src/api/app/bsky/notification/putActivitySubscription.ts @@ -1,12 +1,12 @@ import { TID } from '@atproto/common' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { isActivitySubscriptionEnabled } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { Namespaces } from '../../../../stash' export default function (server: Server, ctx: AppContext) { - server.app.bsky.notification.putActivitySubscription({ + server.add(app.bsky.notification.putActivitySubscription, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const actorDid = auth.credentials.iss diff --git a/packages/bsky/src/api/app/bsky/notification/putPreferences.ts b/packages/bsky/src/api/app/bsky/notification/putPreferences.ts index 901b2549fb5..13887ee0928 100644 --- a/packages/bsky/src/api/app/bsky/notification/putPreferences.ts +++ b/packages/bsky/src/api/app/bsky/notification/putPreferences.ts @@ -1,8 +1,9 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.app.bsky.notification.putPreferences({ + server.add(app.bsky.notification.putPreferences, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const { priority } = input.body diff --git a/packages/bsky/src/api/app/bsky/notification/putPreferencesV2.ts b/packages/bsky/src/api/app/bsky/notification/putPreferencesV2.ts index d3572627a0a..810e00f9d46 100644 --- a/packages/bsky/src/api/app/bsky/notification/putPreferencesV2.ts +++ b/packages/bsky/src/api/app/bsky/notification/putPreferencesV2.ts @@ -1,16 +1,14 @@ import assert from 'node:assert' -import { Un$Typed } from '@atproto/api' -import { UpstreamFailureError } from '@atproto/xrpc-server' +import { Un$Typed } from '@atproto/lex' +import { Server, UpstreamFailureError } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { Preferences } from '../../../../lexicon/types/app/bsky/notification/defs' -import { HandlerInput } from '../../../../lexicon/types/app/bsky/notification/putPreferencesV2' +import { app } from '../../../../lexicons/index.js' import { GetNotificationPreferencesResponse } from '../../../../proto/bsky_pb' import { Namespaces } from '../../../../stash' import { protobufToLex } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.notification.putPreferencesV2({ + server.add(app.bsky.notification.putPreferencesV2, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { const actorDid = auth.credentials.iss @@ -37,8 +35,8 @@ export default function (server: Server, ctx: AppContext) { const computePreferences = async ( ctx: AppContext, actorDid: string, - input: HandlerInput, -): Promise> => { + input: app.bsky.notification.putPreferencesV2.$Input, +): Promise> => { let res: GetNotificationPreferencesResponse try { res = await ctx.dataplane.getNotificationPreferences({ diff --git a/packages/bsky/src/api/app/bsky/notification/registerPush.ts b/packages/bsky/src/api/app/bsky/notification/registerPush.ts index c670672a184..cce6e21e31e 100644 --- a/packages/bsky/src/api/app/bsky/notification/registerPush.ts +++ b/packages/bsky/src/api/app/bsky/notification/registerPush.ts @@ -1,13 +1,14 @@ import { InvalidRequestError, MethodNotImplementedError, + Server, } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { assertLexPlatform, lexPlatformToProtoPlatform } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.notification.registerPush({ + server.add(app.bsky.notification.registerPush, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { if (!ctx.courierClient) { diff --git a/packages/bsky/src/api/app/bsky/notification/unregisterPush.ts b/packages/bsky/src/api/app/bsky/notification/unregisterPush.ts index d029a3bcc2c..5ec267fcb0c 100644 --- a/packages/bsky/src/api/app/bsky/notification/unregisterPush.ts +++ b/packages/bsky/src/api/app/bsky/notification/unregisterPush.ts @@ -1,13 +1,14 @@ import { InvalidRequestError, MethodNotImplementedError, + Server, } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { assertLexPlatform, lexPlatformToProtoPlatform } from './util' export default function (server: Server, ctx: AppContext) { - server.app.bsky.notification.unregisterPush({ + server.add(app.bsky.notification.unregisterPush, { auth: ctx.authVerifier.standard, handler: async ({ auth, input }) => { if (!ctx.courierClient) { diff --git a/packages/bsky/src/api/app/bsky/notification/updateSeen.ts b/packages/bsky/src/api/app/bsky/notification/updateSeen.ts index 2ecdea14c86..d189a91799d 100644 --- a/packages/bsky/src/api/app/bsky/notification/updateSeen.ts +++ b/packages/bsky/src/api/app/bsky/notification/updateSeen.ts @@ -1,10 +1,11 @@ import { Struct, Timestamp } from '@bufbuild/protobuf' import { v3 as murmurV3 } from 'murmurhash' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.app.bsky.notification.updateSeen({ + server.add(app.bsky.notification.updateSeen, { auth: ctx.authVerifier.standard, handler: async ({ input, auth }) => { const viewer = auth.credentials.iss diff --git a/packages/bsky/src/api/app/bsky/notification/util.ts b/packages/bsky/src/api/app/bsky/notification/util.ts index 30867c1a67b..777c7d74a82 100644 --- a/packages/bsky/src/api/app/bsky/notification/util.ts +++ b/packages/bsky/src/api/app/bsky/notification/util.ts @@ -1,10 +1,4 @@ -import { Un$Typed } from '@atproto/api' -import { - ChatPreference, - FilterablePreference, - Preference, - Preferences, -} from '../../../../lexicon/types/app/bsky/notification/defs' +import { app } from '../../../../lexicons/index.js' import { ChatNotificationInclude, ChatNotificationPreference, @@ -22,8 +16,8 @@ type DeepPartial = T extends object : T const ensureChatPreference = ( - p?: DeepPartial, -): ChatPreference => { + p?: DeepPartial, +): app.bsky.notification.defs.ChatPreference => { const includeValues = ['all', 'accepted'] return { include: @@ -35,8 +29,8 @@ const ensureChatPreference = ( } const ensureFilterablePreference = ( - p?: DeepPartial, -): FilterablePreference => { + p?: DeepPartial, +): app.bsky.notification.defs.FilterablePreference => { const includeValues = ['all', 'follows'] return { include: @@ -48,7 +42,9 @@ const ensureFilterablePreference = ( } } -const ensurePreference = (p?: DeepPartial): Preference => { +const ensurePreference = ( + p?: DeepPartial, +): app.bsky.notification.defs.Preference => { return { list: p?.list ?? true, push: p?.push ?? true, @@ -56,8 +52,8 @@ const ensurePreference = (p?: DeepPartial): Preference => { } const ensurePreferences = ( - p: DeepPartial, -): Un$Typed => { + p: DeepPartial, +): app.bsky.notification.defs.Preferences => { return { chat: ensureChatPreference(p.chat), follow: ensureFilterablePreference(p.follow), @@ -77,7 +73,7 @@ const ensurePreferences = ( const protobufChatPreferenceToLex = ( p?: DeepPartial, -): DeepPartial => { +): Partial => { return { include: p?.include === ChatNotificationInclude.ACCEPTED ? 'accepted' : 'all', @@ -87,7 +83,7 @@ const protobufChatPreferenceToLex = ( const protobufFilterablePreferenceToLex = ( p?: DeepPartial, -): DeepPartial => { +): Partial => { return { include: p?.include === NotificationInclude.FOLLOWS ? 'follows' : 'all', list: p?.list?.enabled, @@ -97,7 +93,7 @@ const protobufFilterablePreferenceToLex = ( const protobufPreferenceToLex = ( p?: DeepPartial, -): DeepPartial => { +): Partial => { return { list: p?.list?.enabled, push: p?.push?.enabled, @@ -106,7 +102,7 @@ const protobufPreferenceToLex = ( export const protobufToLex = ( res: DeepPartial, -): Un$Typed => { +): app.bsky.notification.defs.Preferences => { return ensurePreferences({ chat: protobufChatPreferenceToLex(res.chat), follow: protobufFilterablePreferenceToLex(res.follow), diff --git a/packages/bsky/src/api/app/bsky/unspecced/getAgeAssuranceState.ts b/packages/bsky/src/api/app/bsky/unspecced/getAgeAssuranceState.ts index f21222f66b5..7555975c00a 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getAgeAssuranceState.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getAgeAssuranceState.ts @@ -1,22 +1,24 @@ -import { UpstreamFailureError } from '@atproto/xrpc-server' +import { DatetimeString } from '@atproto/syntax' +import { Server, UpstreamFailureError } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { ActorInfo } from '../../../../proto/bsky_pb' export default function (server: Server, ctx: AppContext) { - server.app.bsky.unspecced.getAgeAssuranceState({ + server.add(app.bsky.unspecced.getAgeAssuranceState, { auth: ctx.authVerifier.standard, handler: async ({ auth }) => { const viewer = auth.credentials.iss const actorInfo = await getAgeVerificationState(ctx, viewer) + const lastInitiatedAt = actorInfo.ageAssuranceStatus?.lastInitiatedAt + return { encoding: 'application/json', body: { - lastInitiatedAt: - actorInfo.ageAssuranceStatus?.lastInitiatedAt - ?.toDate() - .toISOString() ?? undefined, + lastInitiatedAt: lastInitiatedAt + ? (lastInitiatedAt.toDate().toISOString() as DatetimeString) + : undefined, status: actorInfo.ageAssuranceStatus?.status ?? 'unknown', }, } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getConfig.ts b/packages/bsky/src/api/app/bsky/unspecced/getConfig.ts index de466e43478..fd29010eff0 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getConfig.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getConfig.ts @@ -1,17 +1,19 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { - server.app.bsky.unspecced.getConfig({ + server.add(app.bsky.unspecced.getConfig, { handler: async () => { return { encoding: 'application/json', body: { checkEmailConfirmed: ctx.cfg.clientCheckEmailConfirmed, + // @ts-expect-error un-specced field topicsEnabled: ctx.cfg.topicsEnabled, liveNow: ctx.cfg.liveNowConfig, - }, + } satisfies app.bsky.unspecced.getConfig.$OutputBody, } }, }) diff --git a/packages/bsky/src/api/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.ts b/packages/bsky/src/api/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.ts index 61d1c0fa63f..e435ddcc9bb 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.ts @@ -1,19 +1,19 @@ -import AtpAgent, { AtUri } from '@atproto/api' -import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' -import { InternalServerError } from '@atproto/xrpc-server' +import { mapDefined, noUndefinedVals } from '@atproto/common' +import { Client } from '@atproto/lex' +import { AtUri, AtUriString, DidString } from '@atproto/syntax' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator, mergeManyStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getOnboardingSuggestedStarterPacks' +import { app } from '../../../../lexicons/index.js' import { - HydrationFnInput, - PresentationFnInput, - RulesFnInput, - SkeletonFnInput, + HydrationFn, + PresentationFn, + RulesFn, + SkeletonFn, createPipeline, } from '../../../../pipeline' import { Views } from '../../../../views' @@ -25,7 +25,7 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.unspecced.getOnboardingSuggestedStarterPacks({ + server.add(app.bsky.unspecced.getOnboardingSuggestedStarterPacks, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -53,45 +53,37 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton: SkeletonFn = async ( + input, +): Promise => { const { params, ctx } = input - if (ctx.topicsAgent) { - const res = - await ctx.topicsAgent.app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - }, - { - headers: params.headers, - }, - ) - return res.data - } else { - throw new InternalServerError('Topics agent not available') + if (!ctx.topicsClient) { + // Use 501 instead of 500 as these are not considered retry-able by clients + throw new MethodNotImplementedError('Topics agent not available') } + + const skeleton = await ctx.topicsClient.call( + app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton, + { limit: params.limit, viewer: params.hydrateCtx.viewer ?? undefined }, + { headers: params.headers }, + ) + + // @TODO Make sure upstream always provides this + skeleton.starterPacks ??= [] + + return skeleton } -const hydration = async ( - input: HydrationFnInput, +const hydration: HydrationFn = async ( + input, ) => { const { ctx, params, skeleton } = input - let dids: string[] = [] - for (const uri of skeleton.starterPacks) { - let aturi: AtUri | undefined - try { - aturi = new AtUri(uri) - } catch { - continue - } - dids.push(aturi.hostname) - } - dids = dedupeStrs(dids) - const pairs: Map = new Map() + + const pairs: Map = new Map() const viewer = params.hydrateCtx.viewer if (viewer) { - pairs.set(viewer, dids) + pairs.set(viewer, getUniqueDidsFromStarterPacks(skeleton.starterPacks)) } const [starterPacksState, bidirectionalBlocks] = await Promise.all([ ctx.hydrator.hydrateStarterPacks(skeleton.starterPacks, params.hydrateCtx), @@ -101,7 +93,7 @@ const hydration = async ( return mergeManyStates(starterPacksState, { bidirectionalBlocks }) } -const noBlocks = (input: RulesFnInput) => { +const noBlocks: RulesFn = (input) => { const { skeleton, params, hydration } = input const viewer = params.hydrateCtx.viewer if (!viewer) { @@ -111,22 +103,23 @@ const noBlocks = (input: RulesFnInput) => { const blocks = hydration.bidirectionalBlocks?.get(viewer) const filteredSkeleton: SkeletonState = { starterPacks: skeleton.starterPacks.filter((uri) => { - let aturi: AtUri | undefined try { - aturi = new AtUri(uri) + return !blocks?.get(new AtUri(uri).did) } catch { return false } - return !blocks?.get(aturi.hostname) }), } return filteredSkeleton } -const presentation = ( - input: PresentationFnInput, -) => { +const presentation: PresentationFn< + Context, + Params, + SkeletonState, + app.bsky.unspecced.getOnboardingSuggestedStarterPacks.$OutputBody +> = (input) => { const { ctx, skeleton, hydration } = input return { @@ -139,14 +132,31 @@ const presentation = ( type Context = { hydrator: Hydrator views: Views - topicsAgent: AtpAgent | undefined + topicsClient: Client | undefined } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string | null } +type Params = app.bsky.unspecced.getOnboardingSuggestedStarterPacks.$Params & { + hydrateCtx: HydrateCtx headers: Record } -type SkeletonState = { - starterPacks: string[] +type SkeletonState = + app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton.$OutputBody + +function getUniqueDidsFromStarterPacks( + starterPacks?: AtUriString[], +): DidString[] { + if (!starterPacks) return [] + + const dids = new Set() + + for (const uri of starterPacks) { + try { + dids.add(new AtUri(uri).did) + } catch { + continue + } + } + + return Array.from(dids) } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index 8753c44ae80..8a3d94b5b16 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -1,14 +1,16 @@ import { mapDefined } from '@atproto/common' +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { parseString } from '../../../../hydration/util' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' import { clearlyBadCursor, resHeaders } from '../../../util' // THIS IS A TEMPORARY UNSPECCED ROUTE // @TODO currently mirrors getSuggestedFeeds and ignores the "query" param. // In the future may take into consideration popularity via likes w/ its own dataplane endpoint. export default function (server: Server, ctx: AppContext) { - server.app.bsky.unspecced.getPopularFeedGenerators({ + server.add(app.bsky.unspecced.getPopularFeedGenerators, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -22,7 +24,7 @@ export default function (server: Server, ctx: AppContext) { } } - let uris: string[] + let uris: AtUriString[] let cursor: string | undefined const query = params.query?.trim() ?? '' @@ -31,14 +33,14 @@ export default function (server: Server, ctx: AppContext) { query, limit: params.limit, }) - uris = res.uris + uris = res.uris as AtUriString[] } else { const res = await ctx.dataplane.getSuggestedFeeds({ actorDid: viewer ?? undefined, limit: params.limit, cursor: params.cursor, }) - uris = res.uris + uris = res.uris as AtUriString[] cursor = parseString(res.cursor) } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getPostThreadOtherV2.ts b/packages/bsky/src/api/app/bsky/unspecced/getPostThreadOtherV2.ts index fb70b425cac..1af92b36844 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getPostThreadOtherV2.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getPostThreadOtherV2.ts @@ -1,9 +1,10 @@ +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { ServerConfig } from '../../../../config' import { AppContext } from '../../../../context' import { Code, DataPlaneClient, isDataplaneError } from '../../../../data-plane' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getPostThreadOtherV2' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -31,10 +32,10 @@ export default function (server: Server, ctx: AppContext) { noRules, // handled in presentation: 3p block-violating replies are turned to #blockedPost, viewer blocks turned to #notFoundPost. presentation, ) - server.app.bsky.unspecced.getPostThreadOtherV2({ + server.add(app.bsky.unspecced.getPostThreadOtherV2, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns, include3pBlocks } = + const { viewer, includeTakedowns, include3pBlocks, skipViewerBlocks } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) const hydrateCtx = await ctx.hydrator.createContext({ @@ -42,6 +43,7 @@ export default function (server: Server, ctx: AppContext) { viewer, includeTakedowns, include3pBlocks, + skipViewerBlocks, }) return { @@ -55,7 +57,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: SkeletonFnInput) => { +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { const { ctx, params } = inputs const anchor = await ctx.hydrator.resolveUri(params.anchor) try { @@ -66,7 +70,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { }) return { anchor, - uris: res.uris, + uris: res.uris as AtUriString[], } } catch (err) { if (isDataplaneError(err, Code.NotFound)) { @@ -108,9 +112,11 @@ type Context = { cfg: ServerConfig } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.unspecced.getPostThreadOtherV2.$Params & { + hydrateCtx: HydrateCtx +} type Skeleton = { - anchor: string - uris: string[] + anchor: AtUriString + uris: AtUriString[] } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getPostThreadV2.ts b/packages/bsky/src/api/app/bsky/unspecced/getPostThreadV2.ts index 308daf6e96d..403822ccede 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getPostThreadV2.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getPostThreadV2.ts @@ -1,9 +1,10 @@ +import { AtUriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { ServerConfig } from '../../../../config' import { AppContext } from '../../../../context' import { Code, DataPlaneClient, isDataplaneError } from '../../../../data-plane' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getPostThreadV2' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -22,21 +23,27 @@ export default function (server: Server, ctx: AppContext) { noRules, // handled in presentation: 3p block-violating replies are turned to #blockedPost, viewer blocks turned to #notFoundPost. presentation, ) - server.app.bsky.unspecced.getPostThreadV2({ + server.add(app.bsky.unspecced.getPostThreadV2, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth, req }) => { - const { viewer, includeTakedowns, include3pBlocks } = + const { viewer, includeTakedowns, include3pBlocks, skipViewerBlocks } = ctx.authVerifier.parseCreds(auth) const labelers = ctx.reqLabelers(req) + const features = ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), + ) + // temp + void features.checkGate(features.Gate.AATest) const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, include3pBlocks, - featureGates: ctx.featureGates.checkGates( - [ctx.featureGates.ids.ThreadsReplyRankingExplorationEnable], - ctx.featureGates.userContext({ did: viewer }), - ), + skipViewerBlocks, + features, }) return { @@ -50,7 +57,9 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (inputs: SkeletonFnInput) => { +const skeleton = async ( + inputs: SkeletonFnInput, +): Promise => { const { ctx, params } = inputs const anchor = await ctx.hydrator.resolveUri(params.anchor) try { @@ -61,7 +70,7 @@ const skeleton = async (inputs: SkeletonFnInput) => { }) return { anchor, - uris: res.uris, + uris: res.uris as AtUriString[], } } catch (err) { if (isDataplaneError(err, Code.NotFound)) { @@ -113,11 +122,13 @@ type Context = { cfg: ServerConfig } -type Params = QueryParams & { hydrateCtx: HydrateCtx } +type Params = app.bsky.unspecced.getPostThreadV2.$Params & { + hydrateCtx: HydrateCtx +} type Skeleton = { - anchor: string - uris: string[] + anchor: AtUriString + uris: AtUriString[] } const calculateAbove = (ctx: Context, params: Params) => { diff --git a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedFeeds.ts b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedFeeds.ts index e67b1b14e6e..6586ff276f2 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedFeeds.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedFeeds.ts @@ -1,10 +1,9 @@ -import AtpAgent from '@atproto/api' import { mapDefined, noUndefinedVals } from '@atproto/common' -import { InternalServerError } from '@atproto/xrpc-server' +import { AtUriString, Client } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getSuggestedFeeds' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -16,7 +15,7 @@ import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { const getFeeds = createPipeline(skeleton, hydration, noRules, presentation) - server.app.bsky.unspecced.getSuggestedFeeds({ + server.add(app.bsky.unspecced.getSuggestedFeeds, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -44,24 +43,26 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input - if (ctx.topicsAgent) { - const res = - await ctx.topicsAgent.app.bsky.unspecced.getSuggestedFeedsSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - }, - { - headers: params.headers, - }, - ) - return res.data - } else { - throw new InternalServerError('Topics agent not available') + if (!ctx.topicsClient) { + // Use 501 instead of 500 as these are not considered retry-able by clients + throw new MethodNotImplementedError('Topics agent not available') } + + return ctx.topicsClient.call( + app.bsky.unspecced.getSuggestedFeedsSkeleton, + { + limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) } const hydration = async ( @@ -86,14 +87,14 @@ const presentation = ( type Context = { hydrator: Hydrator views: Views - topicsAgent: AtpAgent | undefined + topicsClient: Client | undefined } -type Params = QueryParams & { - hydrateCtx: HydrateCtx & { viewer: string | null } +type Params = app.bsky.unspecced.getSuggestedFeeds.$Params & { + hydrateCtx: HydrateCtx headers: Record } type SkeletonState = { - feeds: string[] + feeds: AtUriString[] } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts index 550ebdd1ad2..1081658d048 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts @@ -1,15 +1,13 @@ -import AtpAgent from '@atproto/api' import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' -import { InternalServerError } from '@atproto/xrpc-server' +import { Client, DidString } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { FeatureGates } from '../../../../feature-gates' import { HydrateCtx, Hydrator, mergeManyStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getSuggestedOnboardingUsers' +import { app } from '../../../../lexicons' import { HydrationFnInput, PresentationFnInput, @@ -26,12 +24,15 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrFollows, presentation, ) - server.app.bsky.unspecced.getSuggestedOnboardingUsers({ + server.add(app.bsky.unspecced.getSuggestedOnboardingUsers, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) - const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + }) const headers = noUndefinedVals({ 'accept-language': req.headers['accept-language'], 'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics']) @@ -54,58 +55,23 @@ export default function (server: Server, ctx: AppContext) { }) } -// TODO: rename to `skeleton` once we can fully migrate to Discover -const skeletonFromDiscover = async ( - input: SkeletonFnInput, -) => { - const { params, ctx } = input - if (!ctx.suggestionsAgent) - throw new InternalServerError('Suggestions agent not available') - - const res = - await ctx.suggestionsAgent.app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - category: params.category, - }, - { - headers: params.headers, - }, - ) - - return res.data -} - -const skeletonFromTopics = async (input: SkeletonFnInput) => { +const skeleton = async (input: SkeletonFnInput) => { const { params, ctx } = input - if (!ctx.topicsAgent) - throw new InternalServerError('Topics agent not available') - - // NOTE: This is intentionally using `getSuggestedUsersSkeleton`, not `getSuggestedOnboardingUsersSkeleton`. - // We won't bother implementing `getSuggestedOnboardingUsersSkeleton` in topics since we're phasing out of it. - const res = - await ctx.topicsAgent.app.bsky.unspecced.getSuggestedUsersSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - category: params.category, - }, - { - headers: params.headers, - }, - ) - - return res.data -} + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } -const skeleton = async (input: SkeletonFnInput) => { - const useDiscover = input.ctx.featureGates.check( - input.ctx.featureGates.ids.SuggestedOnboardingUsersDiscoverAgentEnable, - input.ctx.featureGates.userContext({ did: input.params.hydrateCtx.viewer }), + return ctx.suggestionsClient.call( + app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton, + { + limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, + category: params.category, + }, + { + headers: params.headers, + }, ) - const skeletonFn = useDiscover ? skeletonFromDiscover : skeletonFromTopics - return skeletonFn(input) } const hydration = async ( @@ -113,7 +79,7 @@ const hydration = async ( ) => { const { ctx, params, skeleton } = input const dids = dedupeStrs(skeleton.dids) - const pairs: Map = new Map() + const pairs: Map = new Map() const viewer = params.hydrateCtx.viewer if (viewer) { pairs.set(viewer, dids) @@ -150,6 +116,7 @@ const presentation = ( const { ctx, skeleton, hydration } = input return { recId: skeleton.recId, + recIdStr: skeleton.recIdStr, actors: mapDefined(skeleton.dids, (did) => ctx.views.profile(did, hydration), ), @@ -159,18 +126,18 @@ const presentation = ( type Context = { hydrator: Hydrator views: Views - topicsAgent: AtpAgent | undefined - suggestionsAgent: AtpAgent | undefined - featureGates: FeatureGates + topicsClient: Client | undefined + suggestionsClient: Client | undefined } -type Params = QueryParams & { +type Params = app.bsky.unspecced.getSuggestedOnboardingUsers.$Params & { hydrateCtx: HydrateCtx & { viewer: string | null } headers: Record category?: string } type SkeletonState = { - dids: string[] + dids: DidString[] recId?: string + recIdStr?: string } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedStarterPacks.ts b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedStarterPacks.ts index 6365d2b6e58..a2633cdf9ed 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedStarterPacks.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedStarterPacks.ts @@ -1,14 +1,14 @@ -import AtpAgent, { AtUri } from '@atproto/api' -import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' -import { InternalServerError } from '@atproto/xrpc-server' +import { mapDefined, noUndefinedVals } from '@atproto/common' +import { Client } from '@atproto/lex' +import { AtUri, AtUriString, DidString } from '@atproto/syntax' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator, mergeManyStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getSuggestedStarterPacks' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -25,7 +25,7 @@ export default function (server: Server, ctx: AppContext) { noBlocks, presentation, ) - server.app.bsky.unspecced.getSuggestedStarterPacks({ + server.add(app.bsky.unspecced.getSuggestedStarterPacks, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -53,45 +53,42 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input - if (ctx.topicsAgent) { - const res = - await ctx.topicsAgent.app.bsky.unspecced.getSuggestedStarterPacksSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - }, - { - headers: params.headers, - }, - ) - return res.data - } else { - throw new InternalServerError('Topics agent not available') + if (!ctx.topicsClient) { + // Use 501 instead of 500 as these are not considered retry-able by clients + throw new MethodNotImplementedError('Topics agent not available') } + + const skeleton = await ctx.topicsClient.call( + app.bsky.unspecced.getSuggestedStarterPacksSkeleton, + { + limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) + + // @TODO Make sure upstream always provides this + skeleton.starterPacks ??= [] + + return skeleton } const hydration = async ( input: HydrationFnInput, ) => { const { ctx, params, skeleton } = input - let dids: string[] = [] - for (const uri of skeleton.starterPacks) { - let aturi: AtUri | undefined - try { - aturi = new AtUri(uri) - } catch { - continue - } - dids.push(aturi.hostname) - } - dids = dedupeStrs(dids) - const pairs: Map = new Map() + + const pairs: Map = new Map() const viewer = params.hydrateCtx.viewer if (viewer) { - pairs.set(viewer, dids) + pairs.set(viewer, getUniqueDidsFromStarterPacks(skeleton.starterPacks)) } const [starterPacksState, bidirectionalBlocks] = await Promise.all([ ctx.hydrator.hydrateStarterPacks(skeleton.starterPacks, params.hydrateCtx), @@ -111,13 +108,11 @@ const noBlocks = (input: RulesFnInput) => { const blocks = hydration.bidirectionalBlocks?.get(viewer) const filteredSkeleton: SkeletonState = { starterPacks: skeleton.starterPacks.filter((uri) => { - let aturi: AtUri | undefined try { - aturi = new AtUri(uri) + return !blocks?.get(new AtUri(uri).did) } catch { return false } - return !blocks?.get(aturi.hostname) }), } @@ -139,14 +134,32 @@ const presentation = ( type Context = { hydrator: Hydrator views: Views - topicsAgent: AtpAgent | undefined + topicsClient: Client | undefined } -type Params = QueryParams & { +type Params = app.bsky.unspecced.getSuggestedStarterPacks.$Params & { hydrateCtx: HydrateCtx & { viewer: string | null } headers: Record } type SkeletonState = { - starterPacks: string[] + starterPacks: AtUriString[] +} + +function getUniqueDidsFromStarterPacks( + starterPacks?: AtUriString[], +): DidString[] { + if (!starterPacks) return [] + + const dids = new Set() + + for (const uri of starterPacks) { + try { + dids.add(new AtUri(uri).did) + } catch { + continue + } + } + + return Array.from(dids) } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsers.ts b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsers.ts index b7f26d75d12..864712801a5 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsers.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsers.ts @@ -1,15 +1,13 @@ -import AtpAgent from '@atproto/api' import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' -import { InternalServerError } from '@atproto/xrpc-server' +import { Client, DidString } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { FeatureGates } from '../../../../feature-gates' import { HydrateCtx, Hydrator, mergeManyStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getSuggestedUsers' +import { app } from '../../../../lexicons/index.js' import { HydrationFnInput, PresentationFnInput, @@ -26,12 +24,21 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrFollows, presentation, ) - server.app.bsky.unspecced.getSuggestedUsers({ + server.add(app.bsky.unspecced.getSuggestedUsers, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss const labelers = ctx.reqLabelers(req) - const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer }) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + features: ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), + ), + }) const headers = noUndefinedVals({ 'accept-language': req.headers['accept-language'], 'x-bsky-topics': Array.isArray(req.headers['x-bsky-topics']) @@ -57,50 +64,51 @@ export default function (server: Server, ctx: AppContext) { // TODO: rename to `skeleton` once we can fully migrate to Discover const skeletonFromDiscover = async ( input: SkeletonFnInput, -) => { +): Promise => { const { params, ctx } = input - if (!ctx.suggestionsAgent) - throw new InternalServerError('Suggestions agent not available') - - const res = - await ctx.suggestionsAgent.app.bsky.unspecced.getSuggestedUsersSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - category: params.category, - }, - { - headers: params.headers, - }, - ) + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } - return res.data + return ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestedUsersSkeleton, + { + limit: params.limit, + category: params.category, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) } -const skeletonFromTopics = async (input: SkeletonFnInput) => { +const skeletonFromTopics = async ( + input: SkeletonFnInput, +): Promise => { const { params, ctx } = input - if (!ctx.topicsAgent) - throw new InternalServerError('Topics agent not available') - const res = - await ctx.topicsAgent.app.bsky.unspecced.getSuggestedUsersSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - category: params.category, - }, - { - headers: params.headers, - }, - ) + if (!ctx.topicsClient) { + // Use 501 instead of 500 as these are not considered retry-able by clients + throw new MethodNotImplementedError('Topics agent not available') + } - return res.data + return ctx.topicsClient.call( + app.bsky.unspecced.getSuggestedUsersSkeleton, + { + limit: params.limit, + category: params.category, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) } const skeleton = async (input: SkeletonFnInput) => { - const useDiscover = input.ctx.featureGates.check( - input.ctx.featureGates.ids.SuggestedUsersDiscoverAgentEnable, - input.ctx.featureGates.userContext({ did: input.params.hydrateCtx.viewer }), + const useDiscover = input.params.hydrateCtx.features.checkGate( + input.params.hydrateCtx.features.Gate.SuggestedUsersDiscoverEnable, ) const skeletonFn = useDiscover ? skeletonFromDiscover : skeletonFromTopics return skeletonFn(input) @@ -111,7 +119,7 @@ const hydration = async ( ) => { const { ctx, params, skeleton } = input const dids = dedupeStrs(skeleton.dids) - const pairs: Map = new Map() + const pairs: Map = new Map() const viewer = params.hydrateCtx.viewer if (viewer) { pairs.set(viewer, dids) @@ -148,6 +156,7 @@ const presentation = ( const { ctx, skeleton, hydration } = input return { recId: skeleton.recId, + recIdStr: skeleton.recIdStr, actors: mapDefined(skeleton.dids, (did) => ctx.views.profile(did, hydration), ), @@ -157,18 +166,18 @@ const presentation = ( type Context = { hydrator: Hydrator views: Views - topicsAgent: AtpAgent | undefined - suggestionsAgent: AtpAgent | undefined - featureGates: FeatureGates + topicsClient: Client | undefined + suggestionsClient: Client | undefined } -type Params = QueryParams & { +type Params = app.bsky.unspecced.getSuggestedUsers.$Params & { hydrateCtx: HydrateCtx & { viewer: string | null } headers: Record category?: string } type SkeletonState = { - dids: string[] + dids: DidString[] recId?: string + recIdStr?: string } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForDiscover.ts b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForDiscover.ts new file mode 100644 index 00000000000..83eb6c7699f --- /dev/null +++ b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForDiscover.ts @@ -0,0 +1,175 @@ +import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' +import { Client, DidString } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' +import { AppContext } from '../../../../context' +import { + HydrateCtx, + Hydrator, + mergeManyStates, +} from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Views } from '../../../../views' + +export default function (server: Server, ctx: AppContext) { + const getSuggestedUsersForDiscover = createPipeline( + skeleton, + hydration, + noBlocksOrFollows, + presentation, + ) + server.add(app.bsky.unspecced.getSuggestedUsersForDiscover, { + auth: ctx.authVerifier.standardOptional, + handler: async ({ auth, params, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + features: ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), + ), + }) + const headers = noUndefinedVals({ + 'accept-language': req.headers['accept-language'], + }) + const result = await getSuggestedUsersForDiscover( + { + ...params, + hydrateCtx, + headers, + }, + ctx, + ) + return { + encoding: 'application/json', + body: result, + } + }, + }) +} + +const skeletonFromGetSuggestedUsersSkeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } + + return ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestedUsersSkeleton, + { + limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) +} + +// TODO: rename to `skeleton` once we can fully migrate to the new endpoint +const skeletonFromGetSuggestedUsersForDiscoverSkeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } + + return ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestedUsersForDiscoverSkeleton, + { + limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) +} + +const skeleton = async (input: SkeletonFnInput) => { + const useDiscover = input.params.hydrateCtx.features.checkGate( + input.params.hydrateCtx.features.Gate.SuggestedUsersForDiscoverEnable, + ) + const skeletonFn = useDiscover + ? skeletonFromGetSuggestedUsersForDiscoverSkeleton + : skeletonFromGetSuggestedUsersSkeleton + return skeletonFn(input) +} + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const dids = dedupeStrs(skeleton.dids) + const pairs: Map = new Map() + const viewer = params.hydrateCtx.viewer + if (viewer) { + pairs.set(viewer, dids) + } + const [profilesState, bidirectionalBlocks] = await Promise.all([ + ctx.hydrator.hydrateProfiles(dids, params.hydrateCtx), + ctx.hydrator.hydrateBidirectionalBlocks(pairs, params.hydrateCtx), + ]) + + return mergeManyStates(profilesState, { bidirectionalBlocks }) +} + +const noBlocksOrFollows = ( + input: RulesFnInput, +) => { + const { ctx, skeleton, params, hydration } = input + const viewer = params.hydrateCtx.viewer + if (!viewer) { + return skeleton + } + const blocks = hydration.bidirectionalBlocks?.get(viewer) + return { + ...skeleton, + dids: skeleton.dids.filter((did) => { + const viewer = ctx.views.profileViewer(did, hydration) + return !blocks?.get(did) && !viewer?.following + }), + } +} + +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + return { + recIdStr: skeleton.recIdStr, + actors: mapDefined(skeleton.dids, (did) => + ctx.views.profile(did, hydration), + ), + } +} + +type Context = { + hydrator: Hydrator + views: Views + suggestionsClient: Client | undefined +} + +type Params = app.bsky.unspecced.getSuggestedUsersForDiscover.$Params & { + hydrateCtx: HydrateCtx & { viewer: string | null } + headers: Record +} + +type SkeletonState = { + dids: DidString[] + recIdStr?: string +} diff --git a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForExplore.ts b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForExplore.ts new file mode 100644 index 00000000000..27965219e71 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForExplore.ts @@ -0,0 +1,178 @@ +import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' +import { Client, DidString } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' +import { AppContext } from '../../../../context' +import { + HydrateCtx, + Hydrator, + mergeManyStates, +} from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Views } from '../../../../views' + +export default function (server: Server, ctx: AppContext) { + const getSuggestedUsersForExplore = createPipeline( + skeleton, + hydration, + noBlocksOrFollows, + presentation, + ) + server.add(app.bsky.unspecced.getSuggestedUsersForExplore, { + auth: ctx.authVerifier.standardOptional, + handler: async ({ auth, params, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + features: ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), + ), + }) + const headers = noUndefinedVals({ + 'accept-language': req.headers['accept-language'], + }) + const result = await getSuggestedUsersForExplore( + { + ...params, + hydrateCtx, + headers, + }, + ctx, + ) + return { + encoding: 'application/json', + body: result, + } + }, + }) +} + +const skeletonFromGetSuggestedUsersSkeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } + + return ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestedUsersSkeleton, + { + limit: params.limit, + category: params.category, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) +} + +// TODO: rename to `skeleton` once we can fully migrate to the new endpoint +const skeletonFromGetSuggestedUsersForExploreSkeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } + + return ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestedUsersForExploreSkeleton, + { + limit: params.limit, + category: params.category, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) +} + +const skeleton = async (input: SkeletonFnInput) => { + const useExplore = input.params.hydrateCtx.features.checkGate( + input.params.hydrateCtx.features.Gate.SuggestedUsersForExploreEnable, + ) + const skeletonFn = useExplore + ? skeletonFromGetSuggestedUsersForExploreSkeleton + : skeletonFromGetSuggestedUsersSkeleton + return skeletonFn(input) +} + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const dids = dedupeStrs(skeleton.dids) + const pairs: Map = new Map() + const viewer = params.hydrateCtx.viewer + if (viewer) { + pairs.set(viewer, dids) + } + const [profilesState, bidirectionalBlocks] = await Promise.all([ + ctx.hydrator.hydrateProfiles(dids, params.hydrateCtx), + ctx.hydrator.hydrateBidirectionalBlocks(pairs, params.hydrateCtx), + ]) + + return mergeManyStates(profilesState, { bidirectionalBlocks }) +} + +const noBlocksOrFollows = ( + input: RulesFnInput, +) => { + const { ctx, skeleton, params, hydration } = input + const viewer = params.hydrateCtx.viewer + if (!viewer) { + return skeleton + } + const blocks = hydration.bidirectionalBlocks?.get(viewer) + return { + ...skeleton, + dids: skeleton.dids.filter((did) => { + const viewer = ctx.views.profileViewer(did, hydration) + return !blocks?.get(did) && !viewer?.following + }), + } +} + +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + return { + recIdStr: skeleton.recIdStr, + actors: mapDefined(skeleton.dids, (did) => + ctx.views.profile(did, hydration), + ), + } +} + +type Context = { + hydrator: Hydrator + views: Views + suggestionsClient: Client | undefined +} + +type Params = app.bsky.unspecced.getSuggestedUsersForExplore.$Params & { + hydrateCtx: HydrateCtx & { viewer: string | null } + headers: Record + category?: string +} + +type SkeletonState = { + dids: DidString[] + recIdStr?: string +} diff --git a/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForSeeMore.ts b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForSeeMore.ts new file mode 100644 index 00000000000..377bb03d56b --- /dev/null +++ b/packages/bsky/src/api/app/bsky/unspecced/getSuggestedUsersForSeeMore.ts @@ -0,0 +1,178 @@ +import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' +import { Client, DidString } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' +import { AppContext } from '../../../../context' +import { + HydrateCtx, + Hydrator, + mergeManyStates, +} from '../../../../hydration/hydrator' +import { app } from '../../../../lexicons/index.js' +import { + HydrationFnInput, + PresentationFnInput, + RulesFnInput, + SkeletonFnInput, + createPipeline, +} from '../../../../pipeline' +import { Views } from '../../../../views' + +export default function (server: Server, ctx: AppContext) { + const getSuggestedUsersForSeeMore = createPipeline( + skeleton, + hydration, + noBlocksOrFollows, + presentation, + ) + server.add(app.bsky.unspecced.getSuggestedUsersForSeeMore, { + auth: ctx.authVerifier.standardOptional, + handler: async ({ auth, params, req }) => { + const viewer = auth.credentials.iss + const labelers = ctx.reqLabelers(req) + const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + features: ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), + ), + }) + const headers = noUndefinedVals({ + 'accept-language': req.headers['accept-language'], + }) + const result = await getSuggestedUsersForSeeMore( + { + ...params, + hydrateCtx, + headers, + }, + ctx, + ) + return { + encoding: 'application/json', + body: result, + } + }, + }) +} + +const skeletonFromGetSuggestedUsersSkeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } + + return ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestedUsersSkeleton, + { + limit: params.limit, + category: params.category, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) +} + +// TODO: rename to `skeleton` once we can fully migrate to the new endpoint +const skeletonFromGetSuggestedUsersForSeeMoreSkeleton = async ( + input: SkeletonFnInput, +): Promise => { + const { params, ctx } = input + + if (!ctx.suggestionsClient) { + throw new MethodNotImplementedError('Suggestions agent not available') + } + + return ctx.suggestionsClient.call( + app.bsky.unspecced.getSuggestedUsersForSeeMoreSkeleton, + { + limit: params.limit, + category: params.category, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) +} + +const skeleton = async (input: SkeletonFnInput) => { + const useSeeMore = input.params.hydrateCtx.features.checkGate( + input.params.hydrateCtx.features.Gate.SuggestedUsersForSeeMoreEnable, + ) + const skeletonFn = useSeeMore + ? skeletonFromGetSuggestedUsersForSeeMoreSkeleton + : skeletonFromGetSuggestedUsersSkeleton + return skeletonFn(input) +} + +const hydration = async ( + input: HydrationFnInput, +) => { + const { ctx, params, skeleton } = input + const dids = dedupeStrs(skeleton.dids) + const pairs: Map = new Map() + const viewer = params.hydrateCtx.viewer + if (viewer) { + pairs.set(viewer, dids) + } + const [profilesState, bidirectionalBlocks] = await Promise.all([ + ctx.hydrator.hydrateProfiles(dids, params.hydrateCtx), + ctx.hydrator.hydrateBidirectionalBlocks(pairs, params.hydrateCtx), + ]) + + return mergeManyStates(profilesState, { bidirectionalBlocks }) +} + +const noBlocksOrFollows = ( + input: RulesFnInput, +) => { + const { ctx, skeleton, params, hydration } = input + const viewer = params.hydrateCtx.viewer + if (!viewer) { + return skeleton + } + const blocks = hydration.bidirectionalBlocks?.get(viewer) + return { + ...skeleton, + dids: skeleton.dids.filter((did) => { + const viewer = ctx.views.profileViewer(did, hydration) + return !blocks?.get(did) && !viewer?.following + }), + } +} + +const presentation = ( + input: PresentationFnInput, +) => { + const { ctx, skeleton, hydration } = input + return { + recIdStr: skeleton.recIdStr, + actors: mapDefined(skeleton.dids, (did) => + ctx.views.profile(did, hydration), + ), + } +} + +type Context = { + hydrator: Hydrator + views: Views + suggestionsClient: Client | undefined +} + +type Params = app.bsky.unspecced.getSuggestedUsersForSeeMore.$Params & { + hydrateCtx: HydrateCtx & { viewer: string | null } + headers: Record + category?: string +} + +type SkeletonState = { + dids: DidString[] + recIdStr?: string +} diff --git a/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts b/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts index ffe077505db..e6d42d0c8fc 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getTaggedSuggestions.ts @@ -1,16 +1,21 @@ +import { UriString } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { app } from '../../../../lexicons/index.js' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { - server.app.bsky.unspecced.getTaggedSuggestions({ + server.add(app.bsky.unspecced.getTaggedSuggestions, { handler: async () => { const res = await ctx.dataplane.getSuggestedEntities({}) - const suggestions = res.entities.map((entity) => ({ - tag: entity.tag, - subjectType: entity.subjectType, - subject: entity.subject, - })) + const suggestions = res.entities.map( + (entity): app.bsky.unspecced.getTaggedSuggestions.Suggestion => ({ + tag: entity.tag, + subjectType: entity.subjectType, + subject: entity.subject as UriString, + }), + ) + return { encoding: 'application/json', body: { diff --git a/packages/bsky/src/api/app/bsky/unspecced/getTrendingTopics.ts b/packages/bsky/src/api/app/bsky/unspecced/getTrendingTopics.ts index b7a499249de..caffc84d9e6 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getTrendingTopics.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getTrendingTopics.ts @@ -1,16 +1,14 @@ -import AtpAgent from '@atproto/api' import { noUndefinedVals } from '@atproto/common' -import { InternalServerError } from '@atproto/xrpc-server' +import { Client } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { TrendingTopic } from '../../../../lexicon/types/app/bsky/unspecced/defs' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getTrendingTopics' +import { app } from '../../../../lexicons/index.js' import { - HydrationFnInput, - PresentationFnInput, - RulesFnInput, - SkeletonFnInput, + HydrationFn, + PresentationFn, + RulesFn, + SkeletonFn, createPipeline, } from '../../../../pipeline' import { Views } from '../../../../views' @@ -22,7 +20,7 @@ export default function (server: Server, ctx: AppContext) { noBlocksOrMutes, presentation, ) - server.app.bsky.unspecced.getTrendingTopics({ + server.add(app.bsky.unspecced.getTrendingTopics, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -47,56 +45,52 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton: SkeletonFn = async (input) => { const { params, ctx } = input - if (ctx.topicsAgent) { - const res = await ctx.topicsAgent.app.bsky.unspecced.getTrendingTopics( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - }, - { - headers: params.headers, - }, - ) - return res.data - } else { - throw new InternalServerError('Topics agent not available') + + if (!ctx.topicsClient) { + // Use 501 instead of 500 as these are not considered retry-able by clients + throw new MethodNotImplementedError('Topics agent not available') } + + return ctx.topicsClient.call( + app.bsky.unspecced.getTrendingTopics, + { + limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) } -const hydration = async ( - _: HydrationFnInput, -) => { +const hydration: HydrationFn = async () => { return {} } -const noBlocksOrMutes = ( - input: RulesFnInput, -) => { - const { skeleton } = input - return skeleton +const noBlocksOrMutes: RulesFn = (input) => { + return input.skeleton } -const presentation = ( - input: PresentationFnInput, -) => { - const { skeleton } = input - return skeleton +const presentation: PresentationFn< + Context, + Params, + SkeletonState, + SkeletonState +> = (input) => { + return input.skeleton } type Context = { hydrator: Hydrator views: Views - topicsAgent: AtpAgent | undefined + topicsClient: Client | undefined } -type Params = Omit & { +type Params = Omit & { hydrateCtx: HydrateCtx headers: Record } -type SkeletonState = { - topics: TrendingTopic[] - suggested: TrendingTopic[] -} +type SkeletonState = app.bsky.unspecced.getTrendingTopics.$OutputBody diff --git a/packages/bsky/src/api/app/bsky/unspecced/getTrends.ts b/packages/bsky/src/api/app/bsky/unspecced/getTrends.ts index eeced122888..bafac98ff32 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getTrends.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getTrends.ts @@ -1,27 +1,25 @@ -import AtpAgent from '@atproto/api' -import { dedupeStrs, mapDefined, noUndefinedVals } from '@atproto/common' -import { InternalServerError } from '@atproto/xrpc-server' +import { mapDefined, noUndefinedVals } from '@atproto/common' +import { Client, DidString } from '@atproto/lex' +import { MethodNotImplementedError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { HydrateCtx, Hydrator, mergeManyStates, } from '../../../../hydration/hydrator' -import { Server } from '../../../../lexicon' -import { SkeletonTrend } from '../../../../lexicon/types/app/bsky/unspecced/defs' -import { QueryParams } from '../../../../lexicon/types/app/bsky/unspecced/getTrends' +import { app } from '../../../../lexicons/index.js' import { - HydrationFnInput, - PresentationFnInput, - RulesFnInput, - SkeletonFnInput, + HydrationFn, + PresentationFn, + RulesFn, + SkeletonFn, createPipeline, } from '../../../../pipeline' import { Views } from '../../../../views' export default function (server: Server, ctx: AppContext) { const getTrends = createPipeline(skeleton, hydration, noBlocks, presentation) - server.app.bsky.unspecced.getTrends({ + server.add(app.bsky.unspecced.getTrends, { auth: ctx.authVerifier.standardOptional, handler: async ({ auth, params, req }) => { const viewer = auth.credentials.iss @@ -49,38 +47,40 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (input: SkeletonFnInput) => { +const skeleton: SkeletonFn = async (input) => { const { params, ctx } = input - if (ctx.topicsAgent) { - const res = await ctx.topicsAgent.app.bsky.unspecced.getTrendsSkeleton( - { - limit: params.limit, - viewer: params.hydrateCtx.viewer ?? undefined, - }, - { - headers: params.headers, - }, - ) - return res.data - } else { - throw new InternalServerError('Topics agent not available') + + if (!ctx.topicsClient) { + // Use 501 instead of 500 as these are not considered retry-able by clients + throw new MethodNotImplementedError('Topics agent not available') } + + const skeleton = await ctx.topicsClient.call( + app.bsky.unspecced.getTrendsSkeleton, + { + limit: params.limit, + viewer: params.hydrateCtx.viewer ?? undefined, + }, + { + headers: params.headers, + }, + ) + + // @TODO Make sure upstream always provides this + for (const trend of skeleton.trends) trend.dids ??= [] + + return skeleton } -const hydration = async ( - input: HydrationFnInput, +const hydration: HydrationFn = async ( + input, ) => { const { ctx, params, skeleton } = input - let dids: string[] = [] - for (const trend of skeleton.trends) { - dids.push(...trend.dids) - } - dids = dedupeStrs(dids) - const pairs: Map = new Map() + const dids = getUniqueDidsFromTrends(skeleton.trends) + const pairs: Map = new Map() const viewer = params.hydrateCtx.viewer - if (viewer) { - pairs.set(viewer, dids) - } + if (viewer) pairs.set(viewer, dids) + const [profileState, bidirectionalBlocks] = await Promise.all([ ctx.hydrator.hydrateProfilesBasic(dids, params.hydrateCtx), ctx.hydrator.hydrateBidirectionalBlocks(pairs, params.hydrateCtx), @@ -89,7 +89,7 @@ const hydration = async ( return mergeManyStates(profileState, { bidirectionalBlocks }) } -const noBlocks = (input: RulesFnInput) => { +const noBlocks: RulesFn = (input) => { const { skeleton, params, hydration } = input const viewer = params.hydrateCtx.viewer if (!viewer) { @@ -97,19 +97,21 @@ const noBlocks = (input: RulesFnInput) => { } const blocks = hydration.bidirectionalBlocks?.get(viewer) - const filteredSkeleton: SkeletonState = { + + return { trends: skeleton.trends.map((t) => ({ ...t, dids: t.dids.filter((did) => !blocks?.get(did)), })), } - - return filteredSkeleton } -const presentation = ( - input: PresentationFnInput, -) => { +const presentation: PresentationFn< + Context, + Params, + SkeletonState, + app.bsky.unspecced.getTrends.$OutputBody +> = (input) => { const { ctx, skeleton, hydration } = input return { @@ -131,14 +133,30 @@ const presentation = ( type Context = { hydrator: Hydrator views: Views - topicsAgent: AtpAgent | undefined + topicsClient: Client | undefined } -type Params = QueryParams & { +type Params = app.bsky.unspecced.getTrendingTopics.$Params & { hydrateCtx: HydrateCtx & { viewer: string | null } headers: Record } -type SkeletonState = { - trends: SkeletonTrend[] +type SkeletonState = app.bsky.unspecced.getTrendsSkeleton.$OutputBody + +function getUniqueDidsFromTrends( + trends?: app.bsky.unspecced.defs.$defs.SkeletonTrend[], +): DidString[] { + if (!trends) return [] + + const dids = new Set() + + for (const trend of trends) { + if (trend.dids) { + for (const did of trend.dids) { + dids.add(did) + } + } + } + + return Array.from(dids) } diff --git a/packages/bsky/src/api/app/bsky/unspecced/initAgeAssurance.ts b/packages/bsky/src/api/app/bsky/unspecced/initAgeAssurance.ts index e5aeac30431..da812d6d3ba 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/initAgeAssurance.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/initAgeAssurance.ts @@ -4,18 +4,20 @@ import { isDisposableEmail } from 'disposable-email-domains-js' import { InvalidRequestError, MethodNotImplementedError, + Server, } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' import { KwsExternalPayloadError } from '../../../../kws' -import { Server } from '../../../../lexicon' -import { InputSchema } from '../../../../lexicon/types/app/bsky/unspecced/initAgeAssurance' +import { app } from '../../../../lexicons/index.js' import { httpLogger as log } from '../../../../logger' import { ActorInfo } from '../../../../proto/bsky_pb' import { KwsExternalPayload } from '../../../kws/types' import { createStashEvent, getClientUa } from '../../../kws/util' +type InputSchema = app.bsky.unspecced.initAgeAssurance.$InputBody + export default function (server: Server, ctx: AppContext) { - server.app.bsky.unspecced.initAgeAssurance({ + server.add(app.bsky.unspecced.initAgeAssurance, { auth: ctx.authVerifier.standard, handler: async ({ auth, input, req }) => { if (!ctx.kwsClient) { diff --git a/packages/bsky/src/api/blob-resolver.ts b/packages/bsky/src/api/blob-resolver.ts index 4f427f4ef35..9c2e1cac1b7 100644 --- a/packages/bsky/src/api/blob-resolver.ts +++ b/packages/bsky/src/api/blob-resolver.ts @@ -1,7 +1,6 @@ import { Duplex, Transform, Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import createError, { isHttpError } from 'http-errors' -import { CID } from 'multiformats/cid' import { Dispatcher } from 'undici' import { VerifyCidError, @@ -9,6 +8,7 @@ import { createDecoders, } from '@atproto/common' import { AtprotoDid, isAtprotoDid } from '@atproto/did' +import { Cid } from '@atproto/lex' import { ACCEPT_ENCODING_COMPRESSED, ACCEPT_ENCODING_UNCOMPRESSED, @@ -159,7 +159,7 @@ export type StreamBlobFactory = ( info: { url: URL did: AtprotoDid - cid: CID + cid: Cid }, ) => Writable @@ -244,7 +244,7 @@ function parseBlobParams(params: { cid: string; did: string }) { async function getBlobUrl( dataplane: DataPlaneClient, did: string, - cid: CID, + cid: Cid, ): Promise { const pds = await getBlobPds(dataplane, did, cid) @@ -258,7 +258,7 @@ async function getBlobUrl( async function getBlobPds( dataplane: DataPlaneClient, did: string, - cid: CID, + cid: Cid, ): Promise { const [identity, { takenDown }] = await Promise.all([ dataplane.getIdentityByDid({ did }).catch((err) => { @@ -321,7 +321,7 @@ function getBlobHeaders( * If you need the un-compressed data, you should use a decompress + verify * pipeline instead. */ -function createCidVerifier(cid: CID, encoding?: string | string[]): Duplex { +function createCidVerifier(cid: Cid, encoding?: string | string[]): Duplex { // If the upstream content is compressed, we do not want to return a // de-compressed stream here. Indeed, the "compression" middleware will // compress the response before it is sent downstream, if it is not already diff --git a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts index 13a6646ca03..c0c74c19747 100644 --- a/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts +++ b/packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts @@ -1,10 +1,11 @@ import { mapDefined } from '@atproto/common' -import { INVALID_HANDLE } from '@atproto/syntax' +import { DatetimeString, INVALID_HANDLE } from '@atproto/syntax' +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { com } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.com.atproto.admin.getAccountInfos({ + server.add(com.atproto.admin.getAccountInfos, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ params, auth }) => { const { dids } = params @@ -15,22 +16,27 @@ export default function (server: Server, ctx: AppContext) { skipCacheForDids: dids, }) - const infos = mapDefined(dids, (did) => { - const info = actors.get(did) - if (!info) return - if (info.takedownRef && !includeTakedowns) return - const profileRecord = - !info.profileTakedownRef || includeTakedowns - ? info.profile - : undefined + const infos = mapDefined( + dids, + (did): com.atproto.admin.defs.$defs.AccountView | undefined => { + const info = actors.get(did) + if (!info) return + if (info.takedownRef && !includeTakedowns) return + const profileRecord = + !info.profileTakedownRef || includeTakedowns + ? info.profile + : undefined - return { - did, - handle: info.handle ?? INVALID_HANDLE, - relatedRecords: profileRecord ? [profileRecord] : undefined, - indexedAt: (info.sortedAt ?? new Date(0)).toISOString(), - } - }) + return { + did, + handle: info.handle ?? INVALID_HANDLE, + relatedRecords: profileRecord ? [profileRecord] : undefined, + indexedAt: ( + info.sortedAt ?? new Date(0) + ).toISOString() as DatetimeString, + } + }, + ) return { encoding: 'application/json', diff --git a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts index 01c931570b2..24d1957c09a 100644 --- a/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts @@ -1,15 +1,14 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' -import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSubjectStatus' +import { com } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.com.atproto.admin.getSubjectStatus({ + server.add(com.atproto.admin.getSubjectStatus, { auth: ctx.authVerifier.roleOrModService, handler: async ({ params }) => { const { did, uri, blob } = params - let body: OutputSchema | null = null + let body: com.atproto.admin.getSubjectStatus.$OutputBody | null = null if (blob) { if (!did) { throw new InvalidRequestError( diff --git a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts index 14cf1b9a50d..9b34cf38c28 100644 --- a/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts +++ b/packages/bsky/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -1,15 +1,14 @@ import { Timestamp } from '@bufbuild/protobuf' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' -import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' import { - isRepoBlobRef, - isRepoRef, -} from '../../../../lexicon/types/com/atproto/admin/defs' -import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' + AuthRequiredError, + InvalidRequestError, + Server, +} from '@atproto/xrpc-server' +import { AppContext } from '../../../../context' +import { com } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.com.atproto.admin.updateSubjectStatus({ + server.add(com.atproto.admin.updateSubjectStatus, { auth: ctx.authVerifier.roleOrModService, handler: async ({ input, auth }) => { const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth) @@ -21,7 +20,7 @@ export default function (server: Server, ctx: AppContext) { const now = new Date() const { subject, takedown } = input.body if (takedown) { - if (isRepoRef(subject)) { + if (com.atproto.admin.defs.repoRef.$isTypeOf(subject)) { if (takedown.applied) { await ctx.dataplane.takedownActor({ did: subject.did, @@ -34,7 +33,7 @@ export default function (server: Server, ctx: AppContext) { seen: Timestamp.fromDate(now), }) } - } else if (isStrongRef(subject)) { + } else if (com.atproto.repo.strongRef.$isTypeOf(subject)) { if (takedown.applied) { await ctx.dataplane.takedownRecord({ recordUri: subject.uri, @@ -47,7 +46,7 @@ export default function (server: Server, ctx: AppContext) { seen: Timestamp.fromDate(now), }) } - } else if (isRepoBlobRef(subject)) { + } else if (com.atproto.admin.defs.repoBlobRef.$isTypeOf(subject)) { if (takedown.applied) { await ctx.dataplane.takedownBlob({ did: subject.did, @@ -63,7 +62,9 @@ export default function (server: Server, ctx: AppContext) { }) } } else { - throw new InvalidRequestError('Invalid subject') + throw new InvalidRequestError( + `Invalid subject type: ${subject.$type}`, + ) } } diff --git a/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts b/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts index 2ce61290a3f..48d6d3a0630 100644 --- a/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts +++ b/packages/bsky/src/api/com/atproto/identity/resolveHandle.ts @@ -1,13 +1,10 @@ -import * as ident from '@atproto/syntax' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { com } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.com.atproto.identity.resolveHandle(async ({ req, params }) => { - const handle = ident.normalizeHandle(params.handle || req.hostname) - - const [did] = await ctx.hydrator.actor.getDids([handle], { + server.add(com.atproto.identity.resolveHandle, async ({ params }) => { + const [did] = await ctx.hydrator.actor.getDids([params.handle], { lookupUnidirectional: true, }) if (!did) { diff --git a/packages/bsky/src/api/com/atproto/label/queryLabels.ts b/packages/bsky/src/api/com/atproto/label/queryLabels.ts index f9d2b14485d..64b0b9c7cf2 100644 --- a/packages/bsky/src/api/com/atproto/label/queryLabels.ts +++ b/packages/bsky/src/api/com/atproto/label/queryLabels.ts @@ -1,17 +1,23 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { isUriString } from '@atproto/lex' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { com } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.com.atproto.label.queryLabels(async ({ params }) => { + server.add(com.atproto.label.queryLabels, async ({ params }) => { const { uriPatterns, sources } = params - if (uriPatterns.find((uri) => uri.includes('*'))) { + if (!sources?.length) { + throw new InvalidRequestError('source dids are required') + } + + if (uriPatterns.some(includesWildcard)) { throw new InvalidRequestError('wildcards not supported') } - if (!sources?.length) { - throw new InvalidRequestError('source dids are required') + // @TODO Should this be enforced at the schema level? + if (!uriPatterns.every(isUriString)) { + throw new InvalidRequestError('invalid uri pattern') } const labelMap = await ctx.hydrator.label.getLabelsForSubjects( @@ -22,14 +28,15 @@ export default function (server: Server, ctx: AppContext) { redact: new Set(), }, ) - const labels = uriPatterns.flatMap((uri) => labelMap.getBySubject(uri)) + const labels = uriPatterns.flatMap((sub) => labelMap.getBySubject(sub)) return { encoding: 'application/json', - body: { - cursor: undefined, - labels, - }, + body: { labels }, } }) } + +function includesWildcard(str: string) { + return str.includes('*') +} diff --git a/packages/bsky/src/api/com/atproto/repo/getRecord.ts b/packages/bsky/src/api/com/atproto/repo/getRecord.ts index 2ff7bb5913d..74d426ece1f 100644 --- a/packages/bsky/src/api/com/atproto/repo/getRecord.ts +++ b/packages/bsky/src/api/com/atproto/repo/getRecord.ts @@ -1,10 +1,10 @@ import { AtUri } from '@atproto/syntax' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { com } from '../../../../lexicons/index.js' export default function (server: Server, ctx: AppContext) { - server.com.atproto.repo.getRecord({ + server.add(com.atproto.repo.getRecord, { auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ auth, params }) => { const { repo, collection, rkey, cid } = params diff --git a/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts b/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts index 5bbebc40af5..5b6ed84ca4a 100644 --- a/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts +++ b/packages/bsky/src/api/com/atproto/temp/fetchLabels.ts @@ -1,9 +1,9 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' +import { InvalidRequestError, Server } from '@atproto/xrpc-server' import { AppContext } from '../../../../context' -import { Server } from '../../../../lexicon' +import { com } from '../../../../lexicons/index.js' export default function (server: Server, _ctx: AppContext) { - server.com.atproto.temp.fetchLabels(async (_reqCtx) => { + server.add(com.atproto.temp.fetchLabels, async (_reqCtx) => { throw new InvalidRequestError('not implemented on dataplane') }) } diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index 3fb305185ec..1c6a9f20c58 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -1,5 +1,5 @@ +import { Server } from '@atproto/xrpc-server' import { AppContext } from '../context' -import { Server } from '../lexicon' import getProfile from './app/bsky/actor/getProfile' import getProfiles from './app/bsky/actor/getProfiles' import getSuggestions from './app/bsky/actor/getSuggestions' @@ -82,6 +82,9 @@ import getUnspeccedSuggestedFeeds from './app/bsky/unspecced/getSuggestedFeeds' import getSuggestedOnboardingUsers from './app/bsky/unspecced/getSuggestedOnboardingUsers' import getSuggestedStarterPacks from './app/bsky/unspecced/getSuggestedStarterPacks' import getSuggestedUsers from './app/bsky/unspecced/getSuggestedUsers' +import getSuggestedUsersForDiscover from './app/bsky/unspecced/getSuggestedUsersForDiscover' +import getSuggestedUsersForExplore from './app/bsky/unspecced/getSuggestedUsersForExplore' +import getSuggestedUsersForSeeMore from './app/bsky/unspecced/getSuggestedUsersForSeeMore' import getTaggedSuggestions from './app/bsky/unspecced/getTaggedSuggestions' import getTrendingTopics from './app/bsky/unspecced/getTrendingTopics' import getTrends from './app/bsky/unspecced/getTrends' @@ -169,6 +172,9 @@ export default function (server: Server, ctx: AppContext) { getSuggestedOnboardingUsers(server, ctx) getSuggestedStarterPacks(server, ctx) getSuggestedUsers(server, ctx) + getSuggestedUsersForDiscover(server, ctx) + getSuggestedUsersForExplore(server, ctx) + getSuggestedUsersForSeeMore(server, ctx) getUnspeccedSuggestedFeeds(server, ctx) getLabelerServices(server, ctx) searchActors(server, ctx) @@ -200,5 +206,4 @@ export default function (server: Server, ctx: AppContext) { getRecord(server, ctx) fetchLabels(server, ctx) queryLabels(server, ctx) - return server } diff --git a/packages/bsky/src/api/kws/util.ts b/packages/bsky/src/api/kws/util.ts index dfc6252a4cf..feb19a57b99 100644 --- a/packages/bsky/src/api/kws/util.ts +++ b/packages/bsky/src/api/kws/util.ts @@ -1,11 +1,9 @@ import crypto from 'node:crypto' import express from 'express' import { TID } from '@atproto/common' +import { DatetimeString } from '@atproto/syntax' import { AppContext } from '../../context' -import { - AgeAssuranceEvent, - AgeAssuranceState, -} from '../../lexicon/types/app/bsky/unspecced/defs' +import { app } from '../../lexicons/index.js' import { Namespaces } from '../../stash' import { KwsExternalPayload, @@ -33,11 +31,11 @@ export const createStashEvent = async ( initUa?: string completeIp?: string completeUa?: string - status: AgeAssuranceState['status'] + status: app.bsky.unspecced.defs.AgeAssuranceState['status'] }, ) => { - const stashPayload: AgeAssuranceEvent = { - createdAt: new Date().toISOString(), + const stashPayload: app.bsky.unspecced.defs.AgeAssuranceEvent = { + createdAt: new Date().toISOString() as DatetimeString, email, status, attemptId, diff --git a/packages/bsky/src/auth-verifier.ts b/packages/bsky/src/auth-verifier.ts index 834d1c3aa5d..a0225c55320 100644 --- a/packages/bsky/src/auth-verifier.ts +++ b/packages/bsky/src/auth-verifier.ts @@ -4,6 +4,7 @@ import * as jose from 'jose' import KeyEncoder from 'key-encoder' import * as ui8 from 'uint8arrays' import { SECP256K1_JWT_ALG, parseDidKey } from '@atproto/crypto' +import { DidString, isDidString } from '@atproto/lex' import { AuthRequiredError, VerifySignatureWithKeyFn, @@ -46,7 +47,7 @@ type StandardOutput = { credentials: { type: 'standard' aud: string - iss: string + iss: DidString | `${DidString}#${string}` } } @@ -108,7 +109,7 @@ export class AuthVerifier { if (isBasicToken(ctx.req)) { const aud = this.ownDid const iss = ctx.req.headers['appview-as-did'] - if (typeof iss !== 'string' || !iss.startsWith('did:')) { + if (typeof iss !== 'string' || !isDidString(iss)) { throw new AuthRequiredError('bad issuer') } if (!this.parseRoleCreds(ctx.req).admin) { @@ -259,7 +260,7 @@ export class AuthVerifier { credentials: { type: 'standard', aud: this.ownDid, - iss: sub, + iss: sub as DidString | `${DidString}#${string}`, }, } } @@ -302,7 +303,10 @@ export class AuthVerifier { aud: string | null lxmCheck?: (method?: string) => boolean }, - ) { + ): Promise<{ + iss: DidString | `${DidString}#${string}` + aud: string + }> { const getSigningKey = async ( iss: string, _forceRefresh: boolean, // @TODO consider propagating to dataplane @@ -407,6 +411,7 @@ export class AuthVerifier { include3pBlocks: includeTakedownsAnd3pBlocks, canPerformTakedown, isModService, + skipViewerBlocks: isModService && viewer !== null, } } } diff --git a/packages/bsky/src/config.ts b/packages/bsky/src/config.ts index 1002ea6dfd8..d22d990c0ed 100644 --- a/packages/bsky/src/config.ts +++ b/packages/bsky/src/config.ts @@ -1,9 +1,10 @@ import assert from 'node:assert' import { noUndefinedVals } from '@atproto/common' +import { DidString, isDidString } from '@atproto/lex' import { subLogger as log } from './logger' type LiveNowConfig = { - did: string + did: DidString domains: string[] }[] @@ -81,6 +82,7 @@ export interface ServerConfigValues { indexedAtEpoch?: Date // misc/dev blobCacheLocation?: string + eventProxyTrackingEndpoint?: string growthBookApiHost?: string growthBookClientKey?: string // threads @@ -144,6 +146,7 @@ export class ServerConfig { process.env.BSKY_HANDLE_RESOLVE_NAMESERVERS, ) const cdnUrl = process.env.BSKY_CDN_URL || process.env.BSKY_IMG_URI_ENDPOINT + // Values 0 through 16 const etcdHosts = overrides?.etcdHosts ?? envList(process.env.BSKY_ETCD_HOSTS) // e.g. https://video.invalid/watch/%s/%s/playlist.m3u8 @@ -213,6 +216,8 @@ export class ServerConfig { const modServiceDid = process.env.MOD_SERVICE_DID assert(modServiceDid) + const eventProxyTrackingEndpoint = + process.env.BSKY_EVENT_PROXY_TRACKING_ENDPOINT || undefined const growthBookApiHost = process.env.BSKY_GROWTHBOOK_API_HOST || undefined const growthBookClientKey = process.env.NODE_ENV === 'test' @@ -369,6 +374,7 @@ export class ServerConfig { blobRateLimitBypassHostname, adminPasswords, modServiceDid, + eventProxyTrackingEndpoint, growthBookApiHost, growthBookClientKey, clientCheckEmailConfirmed, @@ -563,13 +569,17 @@ export class ServerConfig { } get labelsFromIssuerDids() { - return this.cfg.labelsFromIssuerDids ?? [] + return (this.cfg.labelsFromIssuerDids ?? []) as DidString[] } get blobCacheLocation() { return this.cfg.blobCacheLocation } + get eventProxyTrackingEndpoint() { + return this.cfg.eventProxyTrackingEndpoint + } + get growthBookApiHost() { return this.cfg.growthBookApiHost } @@ -680,6 +690,7 @@ function isLiveNowConfig(data: any): data is LiveNowConfig { typeof item === 'object' && item !== null && typeof item.did === 'string' && + isDidString(item.did) && Array.isArray(item.domains) && item.domains.every((domain: any) => typeof domain === 'string'), ) diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index 5ca4f87a3ac..6aaf47792b0 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -2,15 +2,15 @@ import * as plc from '@did-plc/lib' import { Etcd3 } from 'etcd3' import express from 'express' import { Dispatcher } from 'undici' -import { AtpAgent } from '@atproto/api' import { Keypair } from '@atproto/crypto' import { IdResolver } from '@atproto/identity' +import { Client } from '@atproto/lex' import { AuthVerifier } from './auth-verifier' import { BsyncClient } from './bsync' import { ServerConfig } from './config' import { CourierClient } from './courier' import { DataPlaneClient, HostList } from './data-plane/client' -import { FeatureGates } from './feature-gates' +import { FeatureGatesClient } from './feature-gates' import { Hydrator } from './hydration/hydrator' import { KwsClient } from './kws' import { httpLogger as log } from './logger' @@ -30,9 +30,9 @@ export class AppContext { etcd: Etcd3 | undefined dataplane: DataPlaneClient dataplaneHostList: HostList - searchAgent: AtpAgent | undefined - suggestionsAgent: AtpAgent | undefined - topicsAgent: AtpAgent | undefined + searchClient: Client | undefined + suggestionsClient: Client | undefined + topicsClient: Client | undefined hydrator: Hydrator views: Views signingKey: Keypair @@ -42,7 +42,7 @@ export class AppContext { courierClient: CourierClient | undefined rolodexClient: RolodexClient | undefined authVerifier: AuthVerifier - featureGates: FeatureGates + featureGatesClient: FeatureGatesClient blobDispatcher: Dispatcher kwsClient: KwsClient | undefined }, @@ -64,16 +64,16 @@ export class AppContext { return this.opts.dataplaneHostList } - get searchAgent(): AtpAgent | undefined { - return this.opts.searchAgent + get searchClient(): Client | undefined { + return this.opts.searchClient } - get suggestionsAgent(): AtpAgent | undefined { - return this.opts.suggestionsAgent + get suggestionsClient(): Client | undefined { + return this.opts.suggestionsClient } - get topicsAgent(): AtpAgent | undefined { - return this.opts.topicsAgent + get topicsClient(): Client | undefined { + return this.opts.topicsClient } get hydrator(): Hydrator { @@ -116,8 +116,8 @@ export class AppContext { return this.opts.authVerifier } - get featureGates(): FeatureGates { - return this.opts.featureGates + get featureGatesClient(): FeatureGatesClient { + return this.opts.featureGatesClient } get blobDispatcher(): Dispatcher { diff --git a/packages/bsky/src/data-plane/bsync/index.ts b/packages/bsky/src/data-plane/bsync/index.ts index edd8b84164e..91a8b2ed91d 100644 --- a/packages/bsky/src/data-plane/bsync/index.ts +++ b/packages/bsky/src/data-plane/bsync/index.ts @@ -5,13 +5,9 @@ import { ConnectRouter } from '@connectrpc/connect' import { expressConnectMiddleware } from '@connectrpc/connect-express' import express from 'express' import { TID } from '@atproto/common' -import { jsonStringToLex } from '@atproto/lexicon' +import { lexParse } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import { ids } from '../../lexicon/lexicons' -import { Event as AgeAssuranceV2Event } from '../../lexicon/types/app/bsky/ageassurance/defs' -import { Bookmark } from '../../lexicon/types/app/bsky/bookmark/defs' -import { SubjectActivitySubscription } from '../../lexicon/types/app/bsky/notification/defs' -import { AgeAssuranceEvent } from '../../lexicon/types/app/bsky/unspecced/defs' +import { app } from '../../lexicons/index.js' import { httpLogger } from '../../logger' import { Service } from '../../proto/bsync_connect' import { @@ -66,7 +62,7 @@ const createRoutes = (db: Database) => (router: ConnectRouter) => .execute() } else { const uri = new AtUri(subject) - if (uri.collection === ids.AppBskyGraphList) { + if (uri.collection === app.bsky.graph.list.$type) { await db.db .insertInto('list_mute') .values({ @@ -97,7 +93,7 @@ const createRoutes = (db: Database) => (router: ConnectRouter) => .execute() } else { const uri = new AtUri(subject) - if (uri.collection === ids.AppBskyGraphList) { + if (uri.collection === app.bsky.graph.list.$type) { await db.db .deleteFrom('list_mute') .where('mutedByDid', '=', actorDid) @@ -169,18 +165,20 @@ const createRoutes = (db: Database) => (router: ConnectRouter) => try { if ( namespace === - Namespaces.AppBskyNotificationDefsSubjectActivitySubscription + Namespaces.AppBskyNotificationDefsSubjectActivitySubscription.$type ) { await handleSubjectActivitySubscriptionOperation(db, req, now) } else if ( - namespace === Namespaces.AppBskyUnspeccedDefsAgeAssuranceEvent + namespace === Namespaces.AppBskyUnspeccedDefsAgeAssuranceEvent.$type ) { await handleAgeAssuranceEventOperation(db, req, now) - } else if (namespace === Namespaces.AppBskyAgeassuranceDefsEvent) { + } else if ( + namespace === Namespaces.AppBskyAgeassuranceDefsEvent.$type + ) { await handleAgeAssuranceV2EventOperation(db, req, now) - } else if (namespace === Namespaces.AppBskyBookmarkDefsBookmark) { + } else if (namespace === Namespaces.AppBskyBookmarkDefsBookmark.$type) { await handleBookmarkOperation(db, req, now) - } else if (namespace === Namespaces.AppBskyDraftDefsDraftWithId) { + } else if (namespace === Namespaces.AppBskyDraftDefsDraftWithId.$type) { await handleDraftOperation(db, req, now) } } catch (err) { @@ -260,9 +258,9 @@ const handleSubjectActivitySubscriptionOperation = async ( .execute() } - const parsed = jsonStringToLex( - Buffer.from(payload).toString('utf8'), - ) as SubjectActivitySubscription + const json = Buffer.from(payload).toString('utf8') + const parsed = + lexParse(json) const { subject, activitySubscription: { post, reply }, @@ -302,9 +300,10 @@ const handleAgeAssuranceEventOperation = async ( const { actorDid, method, payload } = req if (method !== Method.CREATE) return - const parsed = jsonStringToLex( + const parsed = lexParse( Buffer.from(payload).toString('utf8'), - ) as AgeAssuranceEvent + ) + const { status, createdAt } = parsed const update = { @@ -327,9 +326,10 @@ const handleAgeAssuranceV2EventOperation = async ( const { actorDid, method, payload } = req if (method !== Method.CREATE) return - const parsed = jsonStringToLex( + const parsed = lexParse( Buffer.from(payload).toString('utf8'), - ) as AgeAssuranceV2Event + ) + const { status, createdAt, access, countryCode, regionCode } = parsed const update = { @@ -373,9 +373,9 @@ const handleBookmarkOperation = async ( } if (method === Method.CREATE) { - const parsed = jsonStringToLex( + const parsed = lexParse( Buffer.from(payload).toString('utf8'), - ) as Bookmark + ) const { subject: { uri, cid }, } = parsed diff --git a/packages/bsky/src/data-plane/client/index.ts b/packages/bsky/src/data-plane/client/index.ts index a3adea18aea..8648ef0300d 100644 --- a/packages/bsky/src/data-plane/client/index.ts +++ b/packages/bsky/src/data-plane/client/index.ts @@ -10,6 +10,7 @@ import { import { createGrpcTransport } from '@connectrpc/connect-node' import { Service } from '../../proto/bsky_connect' import { HostList } from './hosts' +import { callerInterceptor } from './util' export * from './hosts' export * from './util' @@ -105,6 +106,7 @@ const createBaseClient = ( httpVersion, acceptCompression: [], nodeOptions: { rejectUnauthorized }, + interceptors: [callerInterceptor('appview')], }) return createPromiseClient(Service, transport) } diff --git a/packages/bsky/src/data-plane/client/util.test.ts b/packages/bsky/src/data-plane/client/util.test.ts new file mode 100644 index 00000000000..7b54794bcbe --- /dev/null +++ b/packages/bsky/src/data-plane/client/util.test.ts @@ -0,0 +1,39 @@ +/// +import { callerInterceptor } from './util' + +describe('callerInterceptor', () => { + it('sets x-atlantis-caller header on the request', async () => { + const interceptor = callerInterceptor('appview') + const expectedResponse = { status: 'ok' } + const next = jest.fn().mockResolvedValue(expectedResponse) + + const req = { header: new Headers() } + const handler = interceptor(next) + const res = await handler(req as any) + + expect(req.header.get('x-atlantis-caller')).toBe('appview') + expect(next).toHaveBeenCalledWith(req) + expect(res).toBe(expectedResponse) + }) + + it('uses the provided caller value', async () => { + const interceptor = callerInterceptor('feed-generator') + const next = jest.fn().mockResolvedValue({}) + + const req = { header: new Headers() } + await interceptor(next)(req as any) + + expect(req.header.get('x-atlantis-caller')).toBe('feed-generator') + }) + + it('does not overwrite other existing headers', async () => { + const interceptor = callerInterceptor('appview') + const next = jest.fn().mockResolvedValue({}) + + const req = { header: new Headers({ 'x-other': 'value' }) } + await interceptor(next)(req as any) + + expect(req.header.get('x-atlantis-caller')).toBe('appview') + expect(req.header.get('x-other')).toBe('value') + }) +}) diff --git a/packages/bsky/src/data-plane/client/util.ts b/packages/bsky/src/data-plane/client/util.ts index e8bf33312fc..a85efa7b1fe 100644 --- a/packages/bsky/src/data-plane/client/util.ts +++ b/packages/bsky/src/data-plane/client/util.ts @@ -1,7 +1,15 @@ -import { Code, ConnectError } from '@connectrpc/connect' +import { Code, ConnectError, Interceptor } from '@connectrpc/connect' import * as ui8 from 'uint8arrays' import { getDidKeyFromMultibase } from '@atproto/identity' +export const callerInterceptor = + (caller: string): Interceptor => + (next) => + (req) => { + req.header.set('x-atlantis-caller', caller) + return next(req) + } + export const isDataplaneError = ( err: unknown, code?: Code, diff --git a/packages/bsky/src/data-plane/server/indexing/index.ts b/packages/bsky/src/data-plane/server/indexing/index.ts index f5ea78239ee..960fc01e081 100644 --- a/packages/bsky/src/data-plane/server/indexing/index.ts +++ b/packages/bsky/src/data-plane/server/indexing/index.ts @@ -1,9 +1,7 @@ import { Selectable, sql } from 'kysely' -import { CID } from 'multiformats/cid' -import { AtpAgent, ComAtprotoSyncGetLatestCommit } from '@atproto/api' import { DAY, HOUR } from '@atproto/common' import { IdResolver, getPds } from '@atproto/identity' -import { ValidationError } from '@atproto/lexicon' +import { Cid, l, parseCid, xrpc, xrpcSafe } from '@atproto/lex' import { VerifiedRepo, WriteOpAction, @@ -11,7 +9,8 @@ import { readCarWithRoot, verifyRepo, } from '@atproto/repo' -import { AtUri } from '@atproto/syntax' +import { AtUri, DidString } from '@atproto/syntax' +import { com } from '../../../lexicons' import { subLogger } from '../../../logger' import { retryXrpc } from '../../../util/retry' import { BackgroundQueue } from '../background' @@ -36,7 +35,6 @@ import * as StarterPack from './plugins/starter-pack' import * as Status from './plugins/status' import * as Threadgate from './plugins/thread-gate' import * as Verification from './plugins/verification' -import { RecordProcessor } from './processor' export class IndexingService { records: { @@ -96,14 +94,14 @@ export class IndexingService { async indexRecord( uri: AtUri, - cid: CID, + cid: Cid, obj: unknown, action: WriteOpAction.Create | WriteOpAction.Update, timestamp: string, opts?: { disableNotifs?: boolean; disableLabels?: boolean }, ) { this.db.assertNotTransaction() - await this.db.transaction(async (txn) => { + return this.db.transaction(async (txn) => { const indexingTx = this.transact(txn) const indexer = indexingTx.findIndexerForCollection(uri.collection) if (!indexer) return @@ -168,18 +166,17 @@ export class IndexingService { .executeTakeFirst() } - async indexRepo(did: string, commit?: string) { + async indexRepo(did: DidString, commit?: string) { this.db.assertNotTransaction() const now = new Date().toISOString() const { pds, signingKey } = await this.idResolver.did.resolveAtprotoData( did, true, ) - const { api } = new AtpAgent({ service: pds }) - const { data: car } = await retryXrpc(() => - api.com.atproto.sync.getRepo({ did }), - ) + const { body: car } = await retryXrpc(() => { + return xrpc(pds, com.atproto.sync.getRepo, { params: { did } }) + }) const { root, blocks } = await readCarWithRoot(car) const verifiedRepo = await verifyRepo(blocks, root, did, signingKey) @@ -204,7 +201,7 @@ export class IndexingService { ) } } catch (err) { - if (err instanceof ValidationError) { + if (err instanceof l.LexValidationError) { subLogger.warn( { did, commit, uri: uri.toString(), cid: cid.toString() }, 'skipping indexing of invalid record', @@ -230,15 +227,15 @@ export class IndexingService { (acc, cur) => { acc[cur.uri] = { uri: new AtUri(cur.uri), - cid: CID.parse(cur.cid), + cid: parseCid(cur.cid), } return acc }, - {} as Record, + {} as Record, ) } - async setCommitLastSeen(did: string, commit: CID, rev: string) { + async setCommitLastSeen(did: string, commit: Cid, rev: string) { const { ref } = this.db.db.dynamic await this.db.db .insertInto('actor_sync') @@ -258,13 +255,18 @@ export class IndexingService { } findIndexerForCollection(collection: string) { - const indexers = Object.values( - this.records as Record>, - ) - return indexers.find((indexer) => indexer.collection === collection) + for (const indexer of Object.values(this.records)) { + if (indexer.collection === collection) { + return indexer + } + } } - async updateActorStatus(did: string, active: boolean, status: string = '') { + async updateActorStatus( + did: DidString, + active: boolean, + status: string = '', + ) { let upstreamStatus: string | null if (active) { upstreamStatus = null @@ -280,7 +282,7 @@ export class IndexingService { .execute() } - async deleteActor(did: string) { + async deleteActor(did: DidString) { this.db.assertNotTransaction() const actorIsHosted = await this.getActorIsHosted(did) if (actorIsHosted === false) { @@ -293,23 +295,22 @@ export class IndexingService { } } - private async getActorIsHosted(did: string) { + private async getActorIsHosted(did: DidString): Promise { const doc = await this.idResolver.did.resolve(did, true) const pds = doc && getPds(doc) if (!pds) return false - const { api } = new AtpAgent({ service: pds }) - try { - await retryXrpc(() => api.com.atproto.sync.getLatestCommit({ did })) - return true - } catch (err) { - if (err instanceof ComAtprotoSyncGetLatestCommit.RepoNotFoundError) { - return false - } - return null - } + + return retryXrpc(async () => { + const res = await xrpcSafe(pds, com.atproto.sync.getLatestCommit, { + params: { did }, + }) + if (res.success) return true + if (res.error === 'RepoNotFound') return false + throw res.reason // let retryXrpc() handle retries for transient errors + }) } - async unindexActor(did: string) { + async unindexActor(did: DidString) { this.db.assertNotTransaction() // per-record-type indexes await this.db.db.deleteFrom('profile').where('creator', '=', did).execute() @@ -384,7 +385,7 @@ export class IndexingService { type UriAndCid = { uri: AtUri - cid: CID + cid: Cid } type IndexOp = diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/block.ts b/packages/bsky/src/data-plane/server/indexing/plugins/block.ts index 290a376f47c..2b49a5a8c7b 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/block.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/block.ts @@ -1,21 +1,19 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as Block from '../../../../lexicon/types/app/bsky/graph/block' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyGraphBlock type IndexedBlock = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Block.Record, + cid: Cid, + obj: app.bsky.graph.block.Main, timestamp: string, ): Promise => { const inserted = await db @@ -37,7 +35,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, uri: AtUri, - obj: Block.Record, + obj: app.bsky.graph.block.Main, ): Promise => { const found = await db .selectFrom('actor_block') @@ -68,14 +66,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.graph.block.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/chat-declaration.ts b/packages/bsky/src/data-plane/server/indexing/plugins/chat-declaration.ts index 08e168595ab..fd40dbbc5e5 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/chat-declaration.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/chat-declaration.ts @@ -1,6 +1,6 @@ -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' +import { chat } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema } from '../../db/database-schema' @@ -8,12 +8,10 @@ import { RecordProcessor } from '../processor' // @NOTE this indexer is a placeholder to ensure it gets indexed in the generic records table -const lexId = lex.ids.ChatBskyActorDeclaration - const insertFn = async ( _db: DatabaseSchema, uri: AtUri, - _cid: CID, + _cid: Cid, _obj: unknown, _timestamp: string, ): Promise => { @@ -41,14 +39,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { const processor = new RecordProcessor(db, background, { - lexId, + schema: chat.bsky.actor.declaration.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts b/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts index 8e567680d88..04267962ef3 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/feed-generator.ts @@ -1,21 +1,19 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid, getBlobCidString } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as FeedGenerator from '../../../../lexicon/types/app/bsky/feed/generator' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyFeedGenerator type IndexedFeedGenerator = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: FeedGenerator.Record, + cid: Cid, + obj: app.bsky.feed.generator.Main, timestamp: string, ): Promise => { const inserted = await db @@ -30,7 +28,7 @@ const insertFn = async ( descriptionFacets: obj.descriptionFacets ? JSON.stringify(obj.descriptionFacets) : undefined, - avatarCid: obj.avatar?.ref.toString(), + avatarCid: getBlobCidString(obj.avatar), createdAt: normalizeDatetimeAlways(obj.createdAt), indexedAt: timestamp, }) @@ -64,17 +62,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor< - FeedGenerator.Record, - IndexedFeedGenerator -> - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.feed.generator.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts b/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts index 36f8e1d9bb2..3f33a13abf0 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/follow.ts @@ -1,22 +1,20 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as Follow from '../../../../lexicon/types/app/bsky/graph/follow' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { countAll, excluded } from '../../db/util' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyGraphFollow type IndexedFollow = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Follow.Record, + cid: Cid, + obj: app.bsky.graph.follow.Main, timestamp: string, ): Promise => { const inserted = await db @@ -38,7 +36,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, uri: AtUri, - obj: Follow.Record, + obj: app.bsky.graph.follow.Main, ): Promise => { const found = await db .selectFrom('follow') @@ -115,14 +113,10 @@ const updateAggregates = async (db: DatabaseSchema, follow: IndexedFollow) => { await Promise.all([followersCountQb.execute(), followsCountQb.execute()]) } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.graph.follow.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/germ-declaration.ts b/packages/bsky/src/data-plane/server/indexing/plugins/germ-declaration.ts index 078181c1a90..48647b14fdf 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/germ-declaration.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/germ-declaration.ts @@ -1,6 +1,6 @@ -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' +import { com } from '../../../../lexicons/index.js' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema } from '../../db/database-schema' @@ -8,12 +8,10 @@ import { RecordProcessor } from '../processor' // @NOTE this indexer is a placeholder to ensure it gets indexed in the generic records table -const lexId = lex.ids.ComGermnetworkDeclaration - const insertFn = async ( _db: DatabaseSchema, uri: AtUri, - _cid: CID, + _cid: Cid, _obj: unknown, _timestamp: string, ): Promise => { @@ -41,23 +39,16 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { - const processor = new RecordProcessor(db, background, { - lexId, +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { + return new RecordProcessor(db, background, { + schema: com.germnetwork.declaration.main, insertFn, findDuplicate, deleteFn, notifsForInsert, notifsForDelete, }) - // @TODO use lexicon validation - processor.assertValidRecord = () => null - return processor } export default makePlugin diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts b/packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts index 7ee479055ee..9e09a7eb9f9 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/labeler.ts @@ -1,21 +1,19 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as Labeler from '../../../../lexicon/types/app/bsky/labeler/service' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyLabelerService type IndexedLabeler = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Labeler.Record, + cid: Cid, + obj: app.bsky.labeler.service.Main, timestamp: string, ): Promise => { if (uri.rkey !== 'self') return null @@ -58,14 +56,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.labeler.service.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/like.ts b/packages/bsky/src/data-plane/server/indexing/plugins/like.ts index 23a2cf600a4..b7972cc89b5 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/like.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/like.ts @@ -1,8 +1,7 @@ import { Insertable, Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as Like from '../../../../lexicon/types/app/bsky/feed/like' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' @@ -10,16 +9,14 @@ import { Notification } from '../../db/tables/notification' import { countAll, excluded } from '../../db/util' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyFeedLike - type Notif = Insertable type IndexedLike = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Like.Record, + cid: Cid, + obj: app.bsky.feed.like.Main, timestamp: string, ): Promise => { const inserted = await db @@ -44,7 +41,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, uri: AtUri, - obj: Like.Record, + obj: app.bsky.feed.like.Main, ): Promise => { const found = await db .selectFrom('like') @@ -135,14 +132,10 @@ const updateAggregates = async (db: DatabaseSchema, like: IndexedLike) => { await likeCountQb.execute() } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.feed.like.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts index 19cd392bdcc..32dfc782170 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list-block.ts @@ -1,21 +1,19 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as ListBlock from '../../../../lexicon/types/app/bsky/graph/listblock' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyGraphListblock type IndexedListBlock = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: ListBlock.Record, + cid: Cid, + obj: app.bsky.graph.listblock.Main, timestamp: string, ): Promise => { const inserted = await db @@ -37,7 +35,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, uri: AtUri, - obj: ListBlock.Record, + obj: app.bsky.graph.listblock.Main, ): Promise => { const found = await db .selectFrom('list_block') @@ -68,14 +66,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.graph.listblock.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts index 0536176da92..ff06cff1ae1 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list-item.ts @@ -1,22 +1,20 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' -import * as lex from '../../../../lexicon/lexicons' -import * as ListItem from '../../../../lexicon/types/app/bsky/graph/listitem' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyGraphListitem type IndexedListItem = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: ListItem.Record, + cid: Cid, + obj: app.bsky.graph.listitem.Main, timestamp: string, ): Promise => { const listUri = new AtUri(obj.list) @@ -45,7 +43,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, _uri: AtUri, - obj: ListItem.Record, + obj: app.bsky.graph.listitem.Main, ): Promise => { const found = await db .selectFrom('list_item') @@ -76,14 +74,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.graph.listitem.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/list.ts b/packages/bsky/src/data-plane/server/indexing/plugins/list.ts index 7451060d7c2..01ae60b7645 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/list.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/list.ts @@ -1,21 +1,19 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid, getBlobCidString } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as List from '../../../../lexicon/types/app/bsky/graph/list' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyGraphList type IndexedList = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: List.Record, + cid: Cid, + obj: app.bsky.graph.list.Main, timestamp: string, ): Promise => { const inserted = await db @@ -30,7 +28,7 @@ const insertFn = async ( descriptionFacets: obj.descriptionFacets ? JSON.stringify(obj.descriptionFacets) : undefined, - avatarCid: obj.avatar?.ref.toString(), + avatarCid: getBlobCidString(obj.avatar), createdAt: normalizeDatetimeAlways(obj.createdAt), indexedAt: timestamp, }) @@ -64,14 +62,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.graph.list.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/notif-declaration.ts b/packages/bsky/src/data-plane/server/indexing/plugins/notif-declaration.ts index 7bcbe5a728c..a38cd03bb84 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/notif-declaration.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/notif-declaration.ts @@ -1,18 +1,17 @@ -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema } from '../../db/database-schema' import { RecordProcessor } from '../processor' // @NOTE this indexer is a placeholder to ensure it gets indexed in the generic records table -const lexId = lex.ids.AppBskyNotificationDeclaration const insertFn = async ( _db: DatabaseSchema, uri: AtUri, - _cid: CID, + _cid: Cid, _obj: unknown, _timestamp: string, ): Promise => { @@ -40,14 +39,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.notification.declaration.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/post-gate.ts b/packages/bsky/src/data-plane/server/indexing/plugins/post-gate.ts index 34b9bb4b8ad..12747cd8131 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/post-gate.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/post-gate.ts @@ -1,21 +1,19 @@ -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' -import * as lex from '../../../../lexicon/lexicons' -import * as Postgate from '../../../../lexicon/types/app/bsky/feed/postgate' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyFeedPostgate type IndexedGate = DatabaseSchemaType['post_gate'] const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Postgate.Record, + cid: Cid, + obj: app.bsky.feed.postgate.Main, timestamp: string, ): Promise => { const postUri = new AtUri(obj.post) @@ -48,7 +46,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, _uri: AtUri, - obj: Postgate.Record, + obj: app.bsky.feed.postgate.Main, ): Promise => { const found = await db .selectFrom('post_gate') @@ -85,14 +83,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.feed.postgate.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/post.ts b/packages/bsky/src/data-plane/server/indexing/plugins/post.ts index d9bb864713f..9981585163a 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/post.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/post.ts @@ -1,24 +1,7 @@ import { Insertable, Selectable, sql } from 'kysely' -import { CID } from 'multiformats/cid' -import { jsonStringToLex } from '@atproto/lexicon' +import { $Typed, Cid, getBlobCidString, lexParse } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import { isMain as isEmbedExternal } from '../../../../lexicon/types/app/bsky/embed/external' -import { isMain as isEmbedImage } from '../../../../lexicon/types/app/bsky/embed/images' -import { isMain as isEmbedRecord } from '../../../../lexicon/types/app/bsky/embed/record' -import { isMain as isEmbedRecordWithMedia } from '../../../../lexicon/types/app/bsky/embed/recordWithMedia' -import { isMain as isEmbedVideo } from '../../../../lexicon/types/app/bsky/embed/video' -import { - Record as PostRecord, - ReplyRef, -} from '../../../../lexicon/types/app/bsky/feed/post' -import { Record as PostgateRecord } from '../../../../lexicon/types/app/bsky/feed/postgate' -import { Record as GateRecord } from '../../../../lexicon/types/app/bsky/feed/threadgate' -import { - isLink, - isMention, -} from '../../../../lexicon/types/app/bsky/richtext/facet' -import { $Typed } from '../../../../lexicon/util' +import { app } from '../../../../lexicons' import { postUriToPostgateUri, postUriToThreadgateUri, @@ -67,18 +50,16 @@ type IndexedPost = { )[] ancestors?: PostAncestor[] descendents?: PostDescendent[] - threadgate?: GateRecord + threadgate?: app.bsky.feed.threadgate.Main } -const lexId = lex.ids.AppBskyFeedPost - const REPLY_NOTIF_DEPTH = 5 const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: PostRecord, + cid: Cid, + obj: app.bsky.feed.post.Main, timestamp: string, ): Promise => { const post = { @@ -143,13 +124,13 @@ const insertFn = async ( const facets = (obj.facets || []) .flatMap((facet) => facet.features) .flatMap((feature) => { - if (isMention(feature)) { + if (app.bsky.richtext.facet.mention.$matches(feature)) { return { type: 'mention' as const, value: feature.did, } } - if (isLink(feature)) { + if (app.bsky.richtext.facet.link.$matches(feature)) { return { type: 'link' as const, value: feature.uri, @@ -166,28 +147,28 @@ const insertFn = async ( )[] = [] const postEmbeds = separateEmbeds(obj.embed) for (const postEmbed of postEmbeds) { - if (isEmbedImage(postEmbed)) { + if (app.bsky.embed.images.$matches(postEmbed)) { const { images } = postEmbed const imagesEmbed = images.map((img, i) => ({ postUri: uri.toString(), position: i, - imageCid: img.image.ref.toString(), + imageCid: getBlobCidString(img.image), alt: img.alt, })) embeds.push(imagesEmbed) await db.insertInto('post_embed_image').values(imagesEmbed).execute() - } else if (isEmbedExternal(postEmbed)) { + } else if (app.bsky.embed.external.$matches(postEmbed)) { const { external } = postEmbed const externalEmbed = { postUri: uri.toString(), uri: external.uri, title: external.title, description: external.description, - thumbCid: external.thumb?.ref.toString() || null, + thumbCid: getBlobCidString(external.thumb) || null, } embeds.push(externalEmbed) await db.insertInto('post_embed_external').values(externalEmbed).execute() - } else if (isEmbedRecord(postEmbed)) { + } else if (app.bsky.embed.record.$matches(postEmbed)) { const { record } = postEmbed const embedUri = new AtUri(record.uri) const recordEmbed = { @@ -198,7 +179,7 @@ const insertFn = async ( embeds.push(recordEmbed) await db.insertInto('post_embed_record').values(recordEmbed).execute() - if (embedUri.collection === lex.ids.AppBskyFeedPost) { + if (embedUri.collection === app.bsky.feed.post.$type) { const quote = { uri: uri.toString(), cid: cid.toString(), @@ -246,11 +227,11 @@ const insertFn = async ( .executeTakeFirst() } } - } else if (isEmbedVideo(postEmbed)) { + } else if (app.bsky.embed.video.$matches(postEmbed)) { const { video } = postEmbed const videoEmbed = { postUri: uri.toString(), - videoCid: video.ref.toString(), + videoCid: getBlobCidString(video), // @NOTE: alt is required for image but not for video on the lexicon. alt: postEmbed.alt ?? null, } @@ -317,7 +298,7 @@ const notifsForInsert = (obj: IndexedPost) => { for (const embed of obj.embeds ?? []) { if ('embedUri' in embed) { const embedUri = new AtUri(embed.embedUri) - if (embedUri.collection === lex.ids.AppBskyFeedPost) { + if (embedUri.collection === app.bsky.feed.post.$type) { maybeNotify({ did: embedUri.host, reason: 'quote', @@ -428,7 +409,7 @@ const deleteFn = async ( const embedUri = new AtUri(deletedPosts.embedUri) deletedEmbeds.push(deletedPosts) - if (embedUri.collection === lex.ids.AppBskyFeedPost) { + if (embedUri.collection === app.bsky.feed.post.$type) { await db.deleteFrom('quote').where('uri', '=', uriStr).execute() await db .insertInto('post_agg') @@ -504,14 +485,10 @@ const updateAggregates = async (db: DatabaseSchema, postIdx: IndexedPost) => { await Promise.all([replyCountQb?.execute(), postsCountQb.execute()]) } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.feed.post.main, insertFn, findDuplicate, deleteFn, @@ -524,17 +501,17 @@ export const makePlugin = ( export default makePlugin function separateEmbeds( - embed: PostRecord['embed'], + embed: app.bsky.feed.post.Main['embed'], ): Array< | RecordWithMedia['media'] | $Typed - | NonNullable + | NonNullable > { if (!embed) { return [] } - if (isEmbedRecordWithMedia(embed)) { - return [{ $type: lex.ids.AppBskyEmbedRecord, ...embed.record }, embed.media] + if (app.bsky.embed.recordWithMedia.$matches(embed)) { + return [app.bsky.embed.record.$build(embed.record), embed.media] } return [embed] } @@ -542,7 +519,7 @@ function separateEmbeds( async function validateReply( db: DatabaseSchema, creator: string, - reply: ReplyRef, + reply: app.bsky.feed.post.ReplyRef, ) { const replyRefs = await getReplyRefs(db, reply) // check reply @@ -573,7 +550,7 @@ async function getThreadgateRecord(db: DatabaseSchema, postUri: string) { (ref) => ref.uri === threadgateRecordUri, ) if (threadgateRecord) { - return jsonStringToLex(threadgateRecord.json) as GateRecord + return lexParse(threadgateRecord.json) } } @@ -596,7 +573,7 @@ async function validatePostEmbed( const { embeddingRules: { canEmbed }, } = parsePostgate({ - gate: jsonStringToLex(postgateRecord.json) as PostgateRecord, + gate: lexParse(postgateRecord.json), viewerDid: uriToDid(parentUri), authorDid: uriToDid(embedUri), }) @@ -610,7 +587,10 @@ async function validatePostEmbed( } } -async function getReplyRefs(db: DatabaseSchema, reply: ReplyRef) { +async function getReplyRefs( + db: DatabaseSchema, + reply: app.bsky.feed.post.ReplyRef, +) { const replyRoot = reply.root.uri const replyParent = reply.parent.uri const replyGate = postUriToThreadgateUri(replyRoot) @@ -628,16 +608,16 @@ async function getReplyRefs(db: DatabaseSchema, reply: ReplyRef) { root: root && { uri: root.uri, invalidReplyRoot: root.invalidReplyRoot, - record: jsonStringToLex(root.json) as PostRecord, + record: lexParse(root.json), }, parent: parent && { uri: parent.uri, invalidReplyRoot: parent.invalidReplyRoot, - record: jsonStringToLex(parent.json) as PostRecord, + record: lexParse(parent.json), }, gate: gate && { uri: gate.uri, - record: jsonStringToLex(gate.json) as GateRecord, + record: lexParse(gate.json), }, } } diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts b/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts index deeca3f5607..de0d571f9ca 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/profile.ts @@ -1,20 +1,18 @@ -import { CID } from 'multiformats/cid' +import { Cid, getBlobCidString } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as Profile from '../../../../lexicon/types/app/bsky/actor/profile' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyActorProfile type IndexedProfile = DatabaseSchemaType['profile'] const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Profile.Record, + cid: Cid, + obj: app.bsky.actor.profile.Main, timestamp: string, ): Promise => { if (uri.rkey !== 'self') return null @@ -26,8 +24,8 @@ const insertFn = async ( creator: uri.host, displayName: obj.displayName, description: obj.description, - avatarCid: obj.avatar?.ref.toString(), - bannerCid: obj.banner?.ref.toString(), + avatarCid: getBlobCidString(obj.avatar), + bannerCid: getBlobCidString(obj.banner), joinedViaStarterPackUri: obj.joinedViaStarterPack?.uri, createdAt: obj.createdAt ?? new Date().toISOString(), indexedAt: timestamp, @@ -74,14 +72,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.actor.profile.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts b/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts index 084cbf7b640..0f4b6bec741 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/repost.ts @@ -1,8 +1,7 @@ import { Insertable, Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as Repost from '../../../../lexicon/types/app/bsky/feed/repost' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' @@ -10,15 +9,14 @@ import { Notification } from '../../db/tables/notification' import { countAll, excluded } from '../../db/util' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyFeedRepost type Notif = Insertable type IndexedRepost = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Repost.Record, + cid: Cid, + obj: app.bsky.feed.repost.Main, timestamp: string, ): Promise => { const repost = { @@ -62,7 +60,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, uri: AtUri, - obj: Repost.Record, + obj: app.bsky.feed.repost.Main, ): Promise => { const found = await db .selectFrom('repost') @@ -159,14 +157,10 @@ const updateAggregates = async (db: DatabaseSchema, repost: IndexedRepost) => { await repostCountQb.execute() } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.feed.repost.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/starter-pack.ts b/packages/bsky/src/data-plane/server/indexing/plugins/starter-pack.ts index fe237c5cffd..049bca9ba4f 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/starter-pack.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/starter-pack.ts @@ -1,21 +1,19 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as StarterPack from '../../../../lexicon/types/app/bsky/graph/starterpack' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyGraphStarterpack type IndexedStarterPack = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: StarterPack.Record, + cid: Cid, + obj: app.bsky.graph.starterpack.Main, timestamp: string, ): Promise => { const inserted = await db @@ -58,14 +56,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.graph.starterpack.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/status.ts b/packages/bsky/src/data-plane/server/indexing/plugins/status.ts index 45b9f3248a6..afcd1a6dbc4 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/status.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/status.ts @@ -1,6 +1,6 @@ -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema } from '../../db/database-schema' @@ -8,12 +8,10 @@ import { RecordProcessor } from '../processor' // @NOTE this indexer is a placeholder to ensure it gets indexed in the generic records table -const lexId = lex.ids.AppBskyActorStatus - const insertFn = async ( _db: DatabaseSchema, uri: AtUri, - _cid: CID, + _cid: Cid, _obj: unknown, _timestamp: string, ): Promise => { @@ -41,21 +39,16 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { - const processor = new RecordProcessor(db, background, { - lexId, +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { + return new RecordProcessor(db, background, { + schema: app.bsky.actor.status.main, insertFn, findDuplicate, deleteFn, notifsForInsert, notifsForDelete, }) - return processor } export default makePlugin diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts b/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts index a6c53d84e18..d85d29be121 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/thread-gate.ts @@ -1,21 +1,19 @@ -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' -import * as lex from '../../../../lexicon/lexicons' -import * as Threadgate from '../../../../lexicon/types/app/bsky/feed/threadgate' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyFeedThreadgate type IndexedGate = DatabaseSchemaType['thread_gate'] const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Threadgate.Record, + cid: Cid, + obj: app.bsky.feed.threadgate.Main, timestamp: string, ): Promise => { const postUri = new AtUri(obj.post) @@ -48,7 +46,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, _uri: AtUri, - obj: Threadgate.Record, + obj: app.bsky.feed.threadgate.Main, ): Promise => { const found = await db .selectFrom('thread_gate') @@ -85,14 +83,10 @@ const notifsForDelete = () => { return { notifs: [], toDelete: [] } } -export type PluginType = RecordProcessor - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.feed.threadgate.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/plugins/verification.ts b/packages/bsky/src/data-plane/server/indexing/plugins/verification.ts index 3796f003652..60ed8915c48 100644 --- a/packages/bsky/src/data-plane/server/indexing/plugins/verification.ts +++ b/packages/bsky/src/data-plane/server/indexing/plugins/verification.ts @@ -1,21 +1,19 @@ import { Selectable } from 'kysely' -import { CID } from 'multiformats/cid' +import { Cid } from '@atproto/lex' import { AtUri, normalizeDatetimeAlways } from '@atproto/syntax' -import * as lex from '../../../../lexicon/lexicons' -import * as Verification from '../../../../lexicon/types/app/bsky/graph/verification' +import { app } from '../../../../lexicons' import { BackgroundQueue } from '../../background' import { Database } from '../../db' import { DatabaseSchema, DatabaseSchemaType } from '../../db/database-schema' import { RecordProcessor } from '../processor' -const lexId = lex.ids.AppBskyGraphVerification type IndexedVerification = Selectable const insertFn = async ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: Verification.Record, + cid: Cid, + obj: app.bsky.graph.verification.Main, timestamp: string, ): Promise => { const inserted = await db @@ -40,7 +38,7 @@ const insertFn = async ( const findDuplicate = async ( db: DatabaseSchema, uri: AtUri, - obj: Verification.Record, + obj: app.bsky.graph.verification.Main, ): Promise => { const found = await db .selectFrom('verification') @@ -97,17 +95,10 @@ const notifsForDelete = ( } } -export type PluginType = RecordProcessor< - Verification.Record, - IndexedVerification -> - -export const makePlugin = ( - db: Database, - background: BackgroundQueue, -): PluginType => { +export type PluginType = ReturnType +export const makePlugin = (db: Database, background: BackgroundQueue) => { return new RecordProcessor(db, background, { - lexId, + schema: app.bsky.graph.verification.main, insertFn, findDuplicate, deleteFn, diff --git a/packages/bsky/src/data-plane/server/indexing/processor.ts b/packages/bsky/src/data-plane/server/indexing/processor.ts index ade9dea328a..4b6035ee4ae 100644 --- a/packages/bsky/src/data-plane/server/indexing/processor.ts +++ b/packages/bsky/src/data-plane/server/indexing/processor.ts @@ -1,9 +1,7 @@ import { Insertable } from 'kysely' -import { CID } from 'multiformats/cid' import { chunkArray } from '@atproto/common' -import { jsonStringToLex, stringifyLex } from '@atproto/lexicon' +import { Cid, l, lexParse, lexStringify, parseCid } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import { lexicons } from '../../../lexicon/lexicons' import { BackgroundQueue } from '../background' import { Database } from '../db' import { DatabaseSchema } from '../db/database-schema' @@ -11,59 +9,57 @@ import { Notification } from '../db/tables/notification' // @NOTE re: insertions and deletions. Due to how record updates are handled, // (insertFn) should have the same effect as (insertFn -> deleteFn -> insertFn). -type RecordProcessorParams = { - lexId: string +type RecordProcessorOptions = { + schema: TSchema insertFn: ( db: DatabaseSchema, uri: AtUri, - cid: CID, - obj: T, + cid: Cid, + obj: l.InferInput, timestamp: string, - ) => Promise + ) => Promise findDuplicate: ( db: DatabaseSchema, uri: AtUri, - obj: T, + obj: l.InferInput, ) => Promise - deleteFn: (db: DatabaseSchema, uri: AtUri) => Promise - notifsForInsert: (obj: S) => Notif[] + deleteFn: (db: DatabaseSchema, uri: AtUri) => Promise + notifsForInsert: (obj: TRow) => Notif[] notifsForDelete: ( - prev: S, - replacedBy: S | null, + prev: TRow, + replacedBy: TRow | null, ) => { notifs: Notif[]; toDelete: string[] } - updateAggregates?: (db: DatabaseSchema, obj: S) => Promise + updateAggregates?: (db: DatabaseSchema, obj: TRow) => Promise } type Notif = Insertable -export class RecordProcessor { - collection: string - db: DatabaseSchema +export class RecordProcessor { constructor( private appDb: Database, private background: BackgroundQueue, - private params: RecordProcessorParams, - ) { - this.db = appDb.db - this.collection = this.params.lexId + private options: RecordProcessorOptions, + ) {} + + get db() { + return this.appDb.db } - matchesSchema(obj: unknown): obj is T { - try { - this.assertValidRecord(obj) - return true - } catch { - return false - } + get collection(): TSchema['$type'] { + return this.options.schema.$type + } + + matchesSchema(obj: I): obj is I & l.InferInput { + return this.options.schema.matches(obj) } - assertValidRecord(obj: unknown): asserts obj is T { - lexicons.assertValidRecord(this.params.lexId, obj) + assertValidRecord(obj: unknown): asserts obj is l.InferInput { + this.options.schema.check(obj) } async insertRecord( uri: AtUri, - cid: CID, + cid: Cid, obj: unknown, timestamp: string, opts?: { disableNotifs?: boolean }, @@ -75,12 +71,12 @@ export class RecordProcessor { uri: uri.toString(), cid: cid.toString(), did: uri.host, - json: stringifyLex(obj), + json: lexStringify(obj), indexedAt: timestamp, }) .onConflict((oc) => oc.doNothing()) .execute() - const inserted = await this.params.insertFn( + const inserted = await this.options.insertFn( this.db, uri, cid, @@ -95,7 +91,7 @@ export class RecordProcessor { return } // if duplicate, insert into duplicates table with no events - const found = await this.params.findDuplicate(this.db, uri, obj) + const found = await this.options.findDuplicate(this.db, uri, obj) if (found && found.toString() !== uri.toString()) { await this.db .insertInto('duplicate_record') @@ -116,7 +112,7 @@ export class RecordProcessor { // straightforward in the general case. We still get nice control over notifications. async updateRecord( uri: AtUri, - cid: CID, + cid: Cid, obj: unknown, timestamp: string, opts?: { disableNotifs?: boolean }, @@ -127,12 +123,12 @@ export class RecordProcessor { .where('uri', '=', uri.toString()) .set({ cid: cid.toString(), - json: stringifyLex(obj), + json: lexStringify(obj), indexedAt: timestamp, }) .execute() // If the updated record was a dupe, update dupe info for it - const dupe = await this.params.findDuplicate(this.db, uri, obj) + const dupe = await this.options.findDuplicate(this.db, uri, obj) if (dupe) { await this.db .updateTable('duplicate_record') @@ -150,13 +146,13 @@ export class RecordProcessor { .execute() } - const deleted = await this.params.deleteFn(this.db, uri) + const deleted = await this.options.deleteFn(this.db, uri) if (!deleted) { // If a record was updated but hadn't been indexed yet, treat it like a plain insert. return this.insertRecord(uri, cid, obj, timestamp) } this.aggregateOnCommit(deleted) - const inserted = await this.params.insertFn( + const inserted = await this.options.insertFn( this.db, uri, cid, @@ -183,7 +179,7 @@ export class RecordProcessor { .deleteFrom('duplicate_record') .where('uri', '=', uri.toString()) .execute() - const deleted = await this.params.deleteFn(this.db, uri) + const deleted = await this.options.deleteFn(this.db, uri) if (!deleted) return this.aggregateOnCommit(deleted) if (cascading) { @@ -205,14 +201,14 @@ export class RecordProcessor { if (!found) { return this.handleNotifs({ deleted }) } - const record = jsonStringToLex(found.json) + const record = lexParse(found.json) if (!this.matchesSchema(record)) { return this.handleNotifs({ deleted }) } - const inserted = await this.params.insertFn( + const inserted = await this.options.insertFn( this.db, new AtUri(found.uri), - CID.parse(found.cid), + parseCid(found.cid), record, found.indexedAt, ) @@ -223,11 +219,11 @@ export class RecordProcessor { } } - async handleNotifs(op: { deleted?: S; inserted?: S }) { + async handleNotifs(op: { deleted?: TRow; inserted?: TRow }) { let notifs: Notif[] = [] const runOnCommit: ((db: Database) => Promise)[] = [] if (op.deleted) { - const forDelete = this.params.notifsForDelete( + const forDelete = this.options.notifsForDelete( op.deleted, op.inserted ?? null, ) @@ -243,7 +239,7 @@ export class RecordProcessor { } notifs = forDelete.notifs } else if (op.inserted) { - notifs = this.params.notifsForInsert(op.inserted) + notifs = this.options.notifsForInsert(op.inserted) } for (const chunk of chunkArray(notifs, 500)) { runOnCommit.push(async (db) => { @@ -284,8 +280,8 @@ export class RecordProcessor { return !!threadMute } - aggregateOnCommit(indexed: S) { - const { updateAggregates } = this.params + aggregateOnCommit(indexed: TRow) { + const { updateAggregates } = this.options if (!updateAggregates) return this.appDb.onCommit(() => { this.background.add((db) => updateAggregates(db.db, indexed)) diff --git a/packages/bsky/src/data-plane/server/routes/activity-subscription.ts b/packages/bsky/src/data-plane/server/routes/activity-subscription.ts index afb392efcd3..1c476c8cf08 100644 --- a/packages/bsky/src/data-plane/server/routes/activity-subscription.ts +++ b/packages/bsky/src/data-plane/server/routes/activity-subscription.ts @@ -34,7 +34,8 @@ export default (db: Database): Partial> => ({ return { actorDid, namespace: - Namespaces.AppBskyNotificationDefsSubjectActivitySubscription, + Namespaces.AppBskyNotificationDefsSubjectActivitySubscription + .$type, key: '', post: undefined, reply: undefined, @@ -45,7 +46,7 @@ export default (db: Database): Partial> => ({ return { actorDid, namespace: - Namespaces.AppBskyNotificationDefsSubjectActivitySubscription, + Namespaces.AppBskyNotificationDefsSubjectActivitySubscription.$type, key: subject.key, post: subject.post ? {} : undefined, reply: subject.reply ? {} : undefined, diff --git a/packages/bsky/src/data-plane/server/routes/bookmarks.ts b/packages/bsky/src/data-plane/server/routes/bookmarks.ts index ad0e6ead4fc..3bf60bfbc74 100644 --- a/packages/bsky/src/data-plane/server/routes/bookmarks.ts +++ b/packages/bsky/src/data-plane/server/routes/bookmarks.ts @@ -67,7 +67,7 @@ export default (db: Database): Partial> => ({ return { ref: { actorDid, - namespace: Namespaces.AppBskyBookmarkDefsBookmark, + namespace: Namespaces.AppBskyBookmarkDefsBookmark.$type, key: bookmark.key, }, subjectUri: bookmark.subjectUri, diff --git a/packages/bsky/src/data-plane/server/routes/mutes.ts b/packages/bsky/src/data-plane/server/routes/mutes.ts index 58b24e5c827..227668ce198 100644 --- a/packages/bsky/src/data-plane/server/routes/mutes.ts +++ b/packages/bsky/src/data-plane/server/routes/mutes.ts @@ -2,7 +2,7 @@ import assert from 'node:assert' import { ServiceImpl } from '@connectrpc/connect' import { keyBy } from '@atproto/common' import { AtUri } from '@atproto/syntax' -import { ids } from '../../../lexicon/lexicons' +import { app } from '../../../lexicons' import { Service } from '../../../proto/bsky_connect' import { Database } from '../db' import { CreatedAtDidKeyset, TimeCidKeyset, paginate } from '../db/pagination' @@ -186,4 +186,4 @@ export default (db: Database): Partial> => ({ }) const isListUri = (uri: string) => - new AtUri(uri).collection === ids.AppBskyGraphList + new AtUri(uri).collection === app.bsky.graph.list.$type diff --git a/packages/bsky/src/data-plane/server/routes/notifs.ts b/packages/bsky/src/data-plane/server/routes/notifs.ts index 363ce20247f..ee2155b823d 100644 --- a/packages/bsky/src/data-plane/server/routes/notifs.ts +++ b/packages/bsky/src/data-plane/server/routes/notifs.ts @@ -2,13 +2,8 @@ import { Timestamp } from '@bufbuild/protobuf' import { ServiceImpl } from '@connectrpc/connect' import { sql } from 'kysely' import { keyBy } from '@atproto/common' -import { jsonStringToLex } from '@atproto/lexicon' -import { - ChatPreference, - FilterablePreference, - Preference, - Preferences, -} from '../../../lexicon/types/app/bsky/notification/defs' +import { lexParse } from '@atproto/lex' +import { app } from '../../../lexicons/index.js' import { Service } from '../../../proto/bsky_connect' import { ChatNotificationInclude, @@ -179,7 +174,11 @@ export default (db: Database): Partial> => ({ .selectFrom('private_data') .selectAll() .where('actorDid', 'in', dids) - .where('namespace', '=', Namespaces.AppBskyNotificationDefsPreferences) + .where( + 'namespace', + '=', + Namespaces.AppBskyNotificationDefsPreferences.$type, + ) .where('key', '=', 'self') .execute() @@ -189,7 +188,7 @@ export default (db: Database): Partial> => ({ if (!row) { return {} } - const p = jsonStringToLex(row.payload) as Preferences + const p = lexParse(row.payload) return notificationPreferencesLexToProtobuf(p, row.payload) }) @@ -198,11 +197,11 @@ export default (db: Database): Partial> => ({ }) export const notificationPreferencesLexToProtobuf = ( - p: Preferences, + p: app.bsky.notification.defs.Preferences, json: string, ): NotificationPreferences => { const lexChatPreferenceToProtobuf = ( - p: ChatPreference, + p: app.bsky.notification.defs.ChatPreference, ): ChatNotificationPreference => new ChatNotificationPreference({ include: @@ -213,7 +212,7 @@ export const notificationPreferencesLexToProtobuf = ( }) const lexFilterablePreferenceToProtobuf = ( - p: FilterablePreference, + p: app.bsky.notification.defs.FilterablePreference, ): FilterableNotificationPreference => new FilterableNotificationPreference({ include: @@ -224,7 +223,9 @@ export const notificationPreferencesLexToProtobuf = ( push: { enabled: p.push ?? true }, }) - const lexPreferenceToProtobuf = (p: Preference): NotificationPreference => + const lexPreferenceToProtobuf = ( + p: app.bsky.notification.defs.Preference, + ): NotificationPreference => new NotificationPreference({ list: { enabled: p.list ?? true }, push: { enabled: p.push ?? true }, diff --git a/packages/bsky/src/data-plane/server/routes/profile.ts b/packages/bsky/src/data-plane/server/routes/profile.ts index 6842e7d551c..7cff6c1c9c9 100644 --- a/packages/bsky/src/data-plane/server/routes/profile.ts +++ b/packages/bsky/src/data-plane/server/routes/profile.ts @@ -1,12 +1,9 @@ import { Timestamp } from '@bufbuild/protobuf' import { ServiceImpl } from '@connectrpc/connect' import { Selectable, sql } from 'kysely' -import { - AppBskyNotificationDeclaration, - ChatBskyActorDeclaration, -} from '@atproto/api' import { keyBy } from '@atproto/common' -import { parseRecordBytes } from '../../../hydration/util' +import { parseJsonBytes } from '../../../hydration/util' +import { app, chat } from '../../../lexicons/index.js' import { Service } from '../../../proto/bsky_connect' import { VerificationMeta } from '../../../proto/bsky_pb' import { Database } from '../db' @@ -96,7 +93,8 @@ export default (db: Database): Partial> => ({ const status = statuses.records[i] - const chatDeclaration = parseRecordBytes( + const chatDeclaration = parseJsonBytes( + chat.bsky.actor.declaration.main, chatDeclarations.records[i].record, ) @@ -115,7 +113,8 @@ export default (db: Database): Partial> => ({ const ageAssuranceForDids = new Set(returnAgeAssuranceForDids) const activitySubscription = () => { - const record = parseRecordBytes( + const record = parseJsonBytes( + app.bsky.notification.declaration.main, notifDeclarations.records[i].record, ) diff --git a/packages/bsky/src/data-plane/server/routes/records.ts b/packages/bsky/src/data-plane/server/routes/records.ts index 10c8e7b6540..b988cc9c919 100644 --- a/packages/bsky/src/data-plane/server/routes/records.ts +++ b/packages/bsky/src/data-plane/server/routes/records.ts @@ -2,40 +2,42 @@ import { Timestamp } from '@bufbuild/protobuf' import { ServiceImpl } from '@connectrpc/connect' import * as ui8 from 'uint8arrays' import { keyBy } from '@atproto/common' +import { l } from '@atproto/lex' import { AtUri } from '@atproto/syntax' -import { ids } from '../../../lexicon/lexicons' +import { app, chat, com } from '../../../lexicons/index.js' import { Service } from '../../../proto/bsky_connect' import { PostRecordMeta, Record } from '../../../proto/bsky_pb' import { Database } from '../db' export default (db: Database): Partial> => ({ - getBlockRecords: getRecords(db, ids.AppBskyGraphBlock), - getFeedGeneratorRecords: getRecords(db, ids.AppBskyFeedGenerator), - getFollowRecords: getRecords(db, ids.AppBskyGraphFollow), - getLikeRecords: getRecords(db, ids.AppBskyFeedLike), - getListBlockRecords: getRecords(db, ids.AppBskyGraphListblock), - getListItemRecords: getRecords(db, ids.AppBskyGraphListitem), - getListRecords: getRecords(db, ids.AppBskyGraphList), + getBlockRecords: getRecords(db, app.bsky.graph.block), + getFeedGeneratorRecords: getRecords(db, app.bsky.feed.generator), + getFollowRecords: getRecords(db, app.bsky.graph.follow), + getLikeRecords: getRecords(db, app.bsky.feed.like), + getListBlockRecords: getRecords(db, app.bsky.graph.listblock), + getListItemRecords: getRecords(db, app.bsky.graph.listitem), + getListRecords: getRecords(db, app.bsky.graph.list), getPostRecords: getPostRecords(db), - getProfileRecords: getRecords(db, ids.AppBskyActorProfile), - getRepostRecords: getRecords(db, ids.AppBskyFeedRepost), - getThreadGateRecords: getRecords(db, ids.AppBskyFeedThreadgate), - getPostgateRecords: getRecords(db, ids.AppBskyFeedPostgate), - getLabelerRecords: getRecords(db, ids.AppBskyLabelerService), - getActorChatDeclarationRecords: getRecords(db, ids.ChatBskyActorDeclaration), + getProfileRecords: getRecords(db, app.bsky.actor.profile), + getRepostRecords: getRecords(db, app.bsky.feed.repost), + getThreadGateRecords: getRecords(db, app.bsky.feed.threadgate), + getPostgateRecords: getRecords(db, app.bsky.feed.postgate), + getLabelerRecords: getRecords(db, app.bsky.labeler.service), + getActorChatDeclarationRecords: getRecords(db, chat.bsky.actor.declaration), getNotificationDeclarationRecords: getRecords( db, - ids.AppBskyNotificationDeclaration, + app.bsky.notification.declaration, ), - getGermDeclarationRecords: getRecords(db, ids.ComGermnetworkDeclaration), - getStarterPackRecords: getRecords(db, ids.AppBskyGraphStarterpack), - getVerificationRecords: getRecords(db, ids.AppBskyGraphVerification), - getStatusRecords: getRecords(db, ids.AppBskyActorStatus), + getGermDeclarationRecords: getRecords(db, com.germnetwork.declaration), + getStarterPackRecords: getRecords(db, app.bsky.graph.starterpack), + getVerificationRecords: getRecords(db, app.bsky.graph.verification), + getStatusRecords: getRecords(db, app.bsky.actor.status), }) -export const getRecords = - (db: Database, collection?: string) => - async (req: { uris: string[] }): Promise<{ records: Record[] }> => { +export const getRecords = (db: Database, ns?: l.Main) => { + const collection = ns ? l.getMain(ns).$type : undefined + + return async (req: { uris: string[] }): Promise<{ records: Record[] }> => { const validUris = collection ? req.uris.filter((uri) => new AtUri(uri).collection === collection) : req.uris @@ -71,9 +73,10 @@ export const getRecords = }) return { records } } +} export const getPostRecords = (db: Database) => { - const getBaseRecords = getRecords(db, ids.AppBskyFeedPost) + const getBaseRecords = getRecords(db, app.bsky.feed.post) return async (req: { uris: string[] }): Promise<{ records: Record[]; meta: PostRecordMeta[] }> => { diff --git a/packages/bsky/src/data-plane/server/util.ts b/packages/bsky/src/data-plane/server/util.ts index 74f34fbced2..5d4a84107af 100644 --- a/packages/bsky/src/data-plane/server/util.ts +++ b/packages/bsky/src/data-plane/server/util.ts @@ -1,9 +1,5 @@ import { sql } from 'kysely' -import { - Record as PostRecord, - ReplyRef, -} from '../../lexicon/types/app/bsky/feed/post' -import { Record as GateRecord } from '../../lexicon/types/app/bsky/feed/threadgate' +import { GateRecord, PostRecord, PostReplyRef } from '../../views/types' import { parseThreadGate } from '../../views/util' import { DatabaseSchema } from './db/database-schema' import { valuesList } from './db/util' @@ -72,7 +68,7 @@ export const getAncestorsAndSelfQb = ( } export const invalidReplyRoot = ( - reply: ReplyRef, + reply: PostReplyRef, parent: { record: PostRecord invalidReplyRoot: boolean | null diff --git a/packages/bsky/src/feature-gates.ts b/packages/bsky/src/feature-gates.ts deleted file mode 100644 index dd001dacc07..00000000000 --- a/packages/bsky/src/feature-gates.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - GrowthBookClient, - type UserContext as GrowthBookUserContext, -} from '@growthbook/growthbook' -import { featureGatesLogger } from './logger' - -/** - * We want this to be sufficiently high that we don't time out under - * normal conditions, but not so high that it takes too long to boot - * the server. - */ -const FETCH_TIMEOUT = 3e3 // 3 seconds - -/** - * StatSig used to default to every 10s, but I think 1m is fine - */ -const REFETCH_INTERVAL = 60e3 // 1 minute - -export type Config = { - apiHost?: string - clientKey?: string -} - -type UserContext = Omit & { - attributes?: { - did?: string | null - } -} - -export enum FeatureGateID { - /** - * Left here ensure this is interpreted as a string enum and therefore - * appease TS - */ - _ = '', - SuggestedUsersDiscoverAgentEnable = 'suggested_users:discover_agent:enable', - SuggestedOnboardingUsersDiscoverAgentEnable = 'suggested_onboarding_users:discover_agent:enable', - ThreadsReplyRankingExplorationEnable = 'threads:reply_ranking_exploration:enable', - SearchFilteringExplorationEnable = 'search:filtering_exploration:enable', -} - -/** - * Pre-evaluated feature gates map, the result of `FeatureGates.checkGates()` - */ -export type CheckedFeatureGatesMap = Map - -export class FeatureGates { - ready = false - client: GrowthBookClient | undefined = undefined - ids = FeatureGateID - refreshInterval: NodeJS.Timeout | undefined = undefined - - constructor(private config: Config) {} - - async start() { - try { - if (this.config.apiHost && this.config.clientKey) { - this.client = new GrowthBookClient({ - apiHost: this.config.apiHost, - clientKey: this.config.clientKey, - }) - - const { source, error } = await this.client.init({ - timeout: FETCH_TIMEOUT, - }) - - /** - * This does not necessarily mean that the client completely failed, - * since it could just be that the request timed out. It may succeed - * after the timeout, or later during refreshes. - * - * @see https://docs.growthbook.io/lib/node#error-handling - */ - if (error) { - featureGatesLogger.error( - { err: error, source }, - 'Client failed to initialize normally', - ) - } - - /** - * Set up periodic refresh of feature definitions - * - * @see https://docs.growthbook.io/lib/node#refreshing-features - */ - this.refreshInterval = setInterval(async () => { - try { - await this.client?.refreshFeatures({ - timeout: FETCH_TIMEOUT, - }) - } catch (err) { - featureGatesLogger.error({ err }, 'Failed to refresh features') - } - }, REFETCH_INTERVAL) - - /* Ready or not, here we come */ - this.ready = true - } else { - featureGatesLogger.error( - 'Missing required config for FeatureGates client', - ) - } - } catch (err) { - featureGatesLogger.error({ err }, 'Client initialization failed') - this.ready = false - } - } - - destroy() { - if (this.ready) { - this.ready = false - if (this.refreshInterval) { - clearInterval(this.refreshInterval) - } - } - } - - userContext({ - did, - }: Exclude): UserContext { - return { attributes: { did: did ?? null } } - } - - check(gate: FeatureGateID, ctx: UserContext): boolean { - if (!this.ready || !this.client) return false - return this.client.isOn(gate, ctx) - } - - /** - * Pre-evaluate multiple feature gates for a given user, returning a map of - * gate ID to boolean result. - */ - checkGates(gates: FeatureGateID[], ctx: UserContext): CheckedFeatureGatesMap { - return new Map(gates.map((g) => [g, this.check(g, ctx)])) - } -} diff --git a/packages/bsky/src/feature-gates/README.md b/packages/bsky/src/feature-gates/README.md new file mode 100644 index 00000000000..a49dce05410 --- /dev/null +++ b/packages/bsky/src/feature-gates/README.md @@ -0,0 +1,83 @@ +# Feature Gates + +A thin wrapper around [GrowthBook](https://www.growthbook.io/) for feature flag +evaluation and experiment tracking. + +## Usage + +Feature gates are accessible on `HydrationCtx` as `features`. A default "scope" +is defined in `Hydrator.createContext`, which is called by every request +handler. The default scope supplies anonymous `deviceId` and `sessionId` +identifiers for targeting unauthenticated users. + +Feature gates can be checked via the following methods. + +```typescript +// check a single gate +const enabled = ctx.features.checkGate( + ctx.features.Gate.ThreadsReplyRankingExplorationEnable, +) + +// check multiple gates +const gates = ctx.features.checkGates([ + ctx.features.Gate.ThreadsReplyRankingExplorationEnable, + ctx.features.Gate.SomeOtherGate, +]) +const enabled = gates.get( + ctx.features.Gate.ThreadsReplyRankingExplorationEnable, +) +``` + +### User Context + +To accurately gate features by user, we need additional context about that user. +For most use cases, we get this data from within the request handlers. + +Here, the `features` client will be scoped to the current user, allowing for +accurate gate checks throughout the request lifecycle. + +```typescript +const features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope( + ctx.featureGatesClient.parseUserContextFromHandler({ + viewer, + req, + }), +) + +const hydrateCtx = await ctx.hydrator.createContext({ + labelers, + viewer, + features, +}) +``` + +> [!NOTE] +> Although `ctx.featureGatesClient` also has a `checkGates` method, the +> intention is to use the `ScopedFeatureGatesClient` returned by `scope()` for +> gate checks within the application. + +You can also pass the user context directly to `checkGate` or `checkGates`. This +overrides any default scope set on the client, allowing for flexibility in cases +where user context is only available at the point of gate evaluation. + +```typescript +const enabled = ctx.featureGatesClient.checkGate( + ctx.featureGatesClient.Gate.ImageFeatureEnabled, + { did: imageAuthor.did }, +) +``` + +### Adding Gates + +See `gates.ts` and add them to the enum. You can optionally prevent metrics from +being emitted for a gate by adding the gate to the `IGNORE_METRICS_FOR_GATES` +set. + +## Metrics + +Check out the Growthbook docs for more info here. Basically, every feature eval +fires the `onFeatureUsage` callback. Any feature that is part of an experiment +_will also fire the `trackingCallback` callback._ + +For some use cases, this can be noisy and/or performance intensive, so make use +of `IGNORE_METRICS_FOR_GATES` to silence metrics for specific gates. diff --git a/packages/bsky/src/feature-gates/gates.ts b/packages/bsky/src/feature-gates/gates.ts new file mode 100644 index 00000000000..a7a202240a2 --- /dev/null +++ b/packages/bsky/src/feature-gates/gates.ts @@ -0,0 +1,24 @@ +/** + * Enum of all gates in the system. This should be the single source of truth + * for all gates, and should be used in all places where gates are checked or + * defined. + */ +export enum Gate { + SuggestedUsersDiscoverEnable = 'suggested_users:discover_agent:enable', + SuggestedUsersSocialProofEnable = 'suggested_users:social_proof:enable', + ThreadsReplyRankingExplorationEnable = 'threads:reply_ranking_exploration:enable', + SearchFilteringExplorationEnable = 'search:filtering_exploration:enable', + SuggestedUsersForExploreEnable = 'suggested_users:for_explore:enable', + SuggestedUsersForDiscoverEnable = 'suggested_users:for_discover:enable', + SuggestedUsersForSeeMoreEnable = 'suggested_users:for_see_more:enable', + + // temp + AATest = 'aa-test-appview', +} + +/** + * Set of gates that should be ignored when tracking gate evaluations for + * analytics purposes. This is useful for gates that are not user-facing or are + * overly noisy. + */ +export const IGNORE_METRICS_FOR_GATES: Set = new Set([]) diff --git a/packages/bsky/src/feature-gates/index.ts b/packages/bsky/src/feature-gates/index.ts new file mode 100644 index 00000000000..937af28de97 --- /dev/null +++ b/packages/bsky/src/feature-gates/index.ts @@ -0,0 +1,231 @@ +import { GrowthBookClient } from '@growthbook/growthbook' +import type express from 'express' +import { featureGatesLogger } from '../logger' +import { Gate, IGNORE_METRICS_FOR_GATES } from './gates' +import { MetricsClient } from './metrics' +import { + CheckedFeatureGatesMap, + ScopedFeatureGatesClient, + UserContext, +} from './types' +import { + extractUserContextFromGrowthbookUserContext, + mergeUserContexts, + normalizeUserContext, + parsedUserContextToTrackingMetadata, +} from './utils' + +/** + * We want this to be sufficiently high that we don't time out under + * normal conditions, but not so high that it takes too long to boot + * the server. + */ +const FETCH_TIMEOUT = 3e3 // 3 seconds + +/** + * StatSig used to default to every 10s, but I think 1m is fine + */ +const REFETCH_INTERVAL = 60e3 // 1 minute + +/** + * These need to match what the client sends + */ +const ANALYTICS_HEADER_DEVICE_ID = 'X-Bsky-Device-Id' +const ANALYTICS_HEADER_SESSION_ID = 'X-Bsky-Session-Id' + +export { type ScopedFeatureGatesClient } from './types' + +export class FeatureGatesClient { + private ready = false + private client: GrowthBookClient | undefined = undefined + private refreshInterval: NodeJS.Timeout | undefined = undefined + private metrics: MetricsClient + + /** + * Easy access to the `Gate` enum for consumers of this class, so they don't + * need to import it separately. + */ + Gate = Gate + + constructor( + private config: { + growthBookApiHost?: string + growthBookClientKey?: string + eventProxyTrackingEndpoint?: string + }, + ) { + this.metrics = new MetricsClient({ + trackingEndpoint: config.eventProxyTrackingEndpoint, + }) + } + + async start() { + if (!this.config.growthBookApiHost || !this.config.growthBookClientKey) { + featureGatesLogger.info( + {}, + 'feature gates not configured, skipping initialization', + ) + return + } + + try { + this.client = new GrowthBookClient({ + apiHost: this.config.growthBookApiHost, + clientKey: this.config.growthBookClientKey, + onFeatureUsage: (feature, result, userContext) => { + if (IGNORE_METRICS_FOR_GATES.has(feature as Gate)) return + + this.metrics.track( + 'feature:viewed', + { + featureId: feature, + featureResultValue: result.value, + experimentId: result.experiment?.key, + variationId: result.experimentResult?.key, + }, + parsedUserContextToTrackingMetadata( + extractUserContextFromGrowthbookUserContext(userContext), + ), + ) + }, + trackingCallback: (experiment, result, userContext) => { + /** + * Experiments are only fired in a feature gate has an Experiment + * attached in Growthbook. Howerver, we want to be extra sure that a + * misconfigured experiment doesn't result in a huge increase in events, so we + * protect this here. + */ + if ( + result.featureId && + IGNORE_METRICS_FOR_GATES.has(result.featureId as Gate) + ) + return + + this.metrics.track( + 'experiment:viewed', + { + experimentId: experiment.key, + variationId: result.key, + }, + parsedUserContextToTrackingMetadata( + extractUserContextFromGrowthbookUserContext(userContext), + ), + ) + }, + }) + + const { source, error } = await this.client.init({ + timeout: FETCH_TIMEOUT, + }) + + /** + * This does not necessarily mean that the client completely failed, + * since it could just be that the request timed out. It may succeed + * after the timeout, or later during refreshes. + * + * @see https://docs.growthbook.io/lib/node#error-handling + */ + if (error) { + featureGatesLogger.error( + { err: error, source }, + 'Client failed to initialize normally', + ) + } + + /** + * Set up periodic refresh of feature definitions + * + * @see https://docs.growthbook.io/lib/node#refreshing-features + */ + this.refreshInterval = setInterval(async () => { + try { + await this.client?.refreshFeatures({ + timeout: FETCH_TIMEOUT, + }) + } catch (err) { + featureGatesLogger.error({ err }, 'Failed to refresh features') + } + }, REFETCH_INTERVAL) + + /* Ready or not, here we come */ + this.ready = true + } catch (err) { + featureGatesLogger.error({ err }, 'Client initialization failed') + } + } + + destroy() { + if (this.ready) { + this.ready = false + if (this.refreshInterval) { + clearInterval(this.refreshInterval) + } + } + this.metrics.stop() + } + + /** + * Evaluate multiple feature gates for a given user, returning a map of gate + * ID to boolean result. + */ + private checkGates( + gates: Gate[], + userContext: UserContext, + ): CheckedFeatureGatesMap { + const gb = this.client + const attributes = normalizeUserContext(userContext) + if (!gb || !this.ready) return new Map(gates.map((g) => [g, false])) + return new Map(gates.map((g) => [g, gb.isOn(g, { attributes })])) + } + + scope(scopedUserContext: UserContext): ScopedFeatureGatesClient { + /* + * Create initial deviceId and sessionId values for the scoped client, to + * be used throughout this request lifecycle. + */ + const base = normalizeUserContext(scopedUserContext) + return { + Gate: this.Gate, + checkGates: ( + gates: Gate[], + userContextOverrides?: Pick, + ) => { + const userContext = mergeUserContexts(base, userContextOverrides) + return this.checkGates(gates, userContext) + }, + checkGate: (gate: Gate, userContextOverrides?: UserContext) => { + const userContext = mergeUserContexts(base, userContextOverrides) + return this.checkGates([gate], userContext).get(gate) || false + }, + } + } + + /** + * Parse properties available in XRPC handlers to `UserContext`. The returned + * proeprties are used as GrowthBook `attributes` as well as the metadata + * payload for our analytics events. This ensures that the same user properties + * are used for both feature gate targeting and analytics. + */ + parseUserContextFromHandler({ + viewer, + req, + }: { + /** + * The user's DID + */ + viewer: string | null + /** + * The express request object, used to extract analytics headers for the user context + */ + req: express.Request + }): UserContext { + const deviceId = req.header(ANALYTICS_HEADER_DEVICE_ID) + const sessionId = req.header(ANALYTICS_HEADER_SESSION_ID) + + return normalizeUserContext({ + did: viewer, + deviceId, + sessionId, + }) + } +} diff --git a/packages/bsky/src/feature-gates/metrics.test.ts b/packages/bsky/src/feature-gates/metrics.test.ts new file mode 100644 index 00000000000..8b2559153d5 --- /dev/null +++ b/packages/bsky/src/feature-gates/metrics.test.ts @@ -0,0 +1,196 @@ +/// +import { featureGatesLogger } from '../logger' +import { MetricsClient } from './metrics' + +jest.mock('../logger', () => ({ + featureGatesLogger: { + error: jest.fn(), + }, +})) + +type TestEvents = { + click: { button: string } + view: { screen: string } +} + +// Helper to flush promises and timers +const flushPromises = () => new Promise((r) => setImmediate(r)) + +describe('MetricsClient', () => { + let fetchMock: jest.Mock + let fetchRequests: { body: any }[] + let client: MetricsClient + + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] }) + fetchRequests = [] + fetchMock = jest.fn().mockImplementation(async (_url, options) => { + const body = JSON.parse(options.body) + fetchRequests.push({ body }) + return { ok: true, status: 200, text: async () => '' } + }) + global.fetch = fetchMock + }) + + afterEach(() => { + client?.stop() + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('flushes events on interval', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', { button: 'submit' }) + client.track('view', { screen: 'home' }) + + expect(fetchRequests).toHaveLength(0) + + // Advance past the 10 second interval + jest.advanceTimersByTime(10_000) + await flushPromises() + + expect(fetchRequests).toHaveLength(1) + expect(fetchRequests[0].body.events).toHaveLength(2) + expect(fetchRequests[0].body.events[0].event).toBe('click') + expect(fetchRequests[0].body.events[1].event).toBe('view') + }) + + it('flushes when maxBatchSize is exceeded', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.maxBatchSize = 5 + + // Add events up to maxBatchSize (should not flush yet) + for (let i = 0; i < 5; i++) { + client.track('click', { button: `btn-${i}` }) + } + + expect(fetchRequests).toHaveLength(0) + + // One more event should trigger flush (> maxBatchSize) + client.track('click', { button: 'btn-trigger' }) + await flushPromises() + + expect(fetchRequests).toHaveLength(1) + expect(fetchRequests[0].body.events).toHaveLength(6) + }) + + it('logs error on failed request', async () => { + fetchMock.mockImplementation(async () => { + return { + ok: false, + status: 500, + text: async () => 'Internal Server Error', + } + }) + + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', { button: 'submit' }) + + // Trigger flush via interval + jest.advanceTimersByTime(10_000) + await flushPromises() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(featureGatesLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.any(Error), + }), + 'Failed to send metrics', + ) + }) + + it('handles fetch text() error gracefully', async () => { + fetchMock.mockImplementation(async () => { + return { + ok: false, + status: 500, + text: async () => { + throw new Error('Failed to read response') + }, + } + }) + + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', { button: 'submit' }) + + // Trigger flush - should not throw + jest.advanceTimersByTime(10_000) + await flushPromises() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(featureGatesLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.objectContaining({ + message: expect.stringContaining('Unknown error'), + }), + }), + 'Failed to send metrics', + ) + }) + + it('flushes when stop() is called', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', { button: 'submit' }) + + expect(fetchRequests).toHaveLength(0) + + // Stop should flush remaining events + client.stop() + await flushPromises() + + expect(fetchRequests).toHaveLength(1) + expect(fetchRequests[0].body.events).toHaveLength(1) + expect(fetchRequests[0].body.events[0].event).toBe('click') + }) + + it('does not send if trackingEndpoint is not configured', async () => { + client = new MetricsClient({}) + client.track('click', { button: 'submit' }) + + // Trigger flush via interval + jest.advanceTimersByTime(10_000) + await flushPromises() + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('start() is idempotent', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + + // track() calls start() internally + client.track('click', { button: 'submit' }) + client.start() + client.start() + + // Advance past interval - should only flush once + jest.advanceTimersByTime(10_000) + await flushPromises() + + expect(fetchRequests).toHaveLength(1) + }) + + it('does not flush if queue is empty', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.start() + + // Advance past interval with empty queue + jest.advanceTimersByTime(10_000) + await flushPromises() + + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/bsky/src/feature-gates/metrics.ts b/packages/bsky/src/feature-gates/metrics.ts new file mode 100644 index 00000000000..169c62c045a --- /dev/null +++ b/packages/bsky/src/feature-gates/metrics.ts @@ -0,0 +1,107 @@ +import { featureGatesLogger } from '../logger' + +type Events = { + 'experiment:viewed': { + experimentId: string + variationId: string + } + 'feature:viewed': { + featureId: string + featureResultValue: unknown + /** Only available if feature has experiment rules applied */ + experimentId?: string + /** Only available if feature has experiment rules applied */ + variationId?: string + } +} + +type Event> = { + time: number + event: keyof M + payload: M[keyof M] + metadata: Record +} + +export type Config = { + trackingEndpoint?: string +} + +export class MetricsClient = Events> { + maxBatchSize = 100 + + private started: boolean = false + private queue: Event[] = [] + private flushInterval: NodeJS.Timeout | null = null + constructor(private config: Config) {} + + start() { + if (this.started) return + this.started = true + this.flushInterval = setInterval(() => { + this.flush() + }, 10_000) + } + + stop() { + if (this.flushInterval) { + clearInterval(this.flushInterval) + this.flushInterval = null + } + this.flush() + } + + track( + event: E, + payload: M[E], + metadata: Record = {}, + ) { + this.start() + + const e = { + source: 'appview', + time: Date.now(), + event, + payload, + metadata, + } + this.queue.push(e) + + if (this.queue.length > this.maxBatchSize) { + this.flush() + } + } + + flush() { + if (!this.queue.length) return + const events = this.queue.splice(0, this.queue.length) + this.sendBatch(events) + } + + private async sendBatch(events: Event[]) { + if (!this.config.trackingEndpoint) return + + try { + const res = await fetch(this.config.trackingEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ events }), + keepalive: true, + }) + + if (!res.ok) { + const errorText = await res.text().catch(() => 'Unknown error') + featureGatesLogger.error( + { err: new Error(`${res.status} Failed to fetch - ${errorText}`) }, + 'Failed to send metrics', + ) + } else { + // Drain response body to allow connection reuse. + await res.text().catch(() => {}) + } + } catch (err) { + featureGatesLogger.error({ err }, 'Failed to send metrics') + } + } +} diff --git a/packages/bsky/src/feature-gates/types.ts b/packages/bsky/src/feature-gates/types.ts new file mode 100644 index 00000000000..70c64415487 --- /dev/null +++ b/packages/bsky/src/feature-gates/types.ts @@ -0,0 +1,62 @@ +import { Gate } from './gates' + +/** + * The user context passed to the feature gates client for evaluation and + * tracking purposes. + */ +export type UserContext = { + did?: string | null + deviceId?: string | null + sessionId?: string | null +} + +/** + * User context that has been normalized to ensure all required properties are + * present and have fallback values. This is the format that we expect to use + * for all feature gate evaluations and analytics tracking. + */ +export type NormalizedUserContext = { + did?: string + deviceId: string + sessionId: string +} + +/** + * This loosely matches the metadata we send from the client for analytics + * events. We want to make sure we have the same properties in both places so + * that we can correlate feature gate evaluations with analytics events. + * + * @see https://github.com/bluesky-social/social-app/blob/76109a58dc7aafccdfbd07a81cbd9925e065d1c0/src/analytics/metadata.ts + */ +export type TrackingMetadata = { + base: { + deviceId: string + sessionId: string + } + session: { + did: string | undefined + } +} + +/** + * Pre-evaluated feature gates map, the result of + * `ctx.FeatureGatesClient.checkGates()` + */ +export type CheckedFeatureGatesMap = Map + +/** + * The primary interface for interacting with feature gates in the system. This + * client is designed to be used in the context of an XRPC handler, where we + * can parse the user context from the request and then create a scoped client + * for that request lifecycle. The client provides methods for checking multiple + * gates at once, as well as checking individual gates with optional user + * context overrides. + */ +export type ScopedFeatureGatesClient = { + Gate: typeof Gate + checkGates( + gates: Gate[], + userContextOverrides?: UserContext, + ): CheckedFeatureGatesMap + checkGate(gate: Gate, userContextOverrides?: UserContext): boolean +} diff --git a/packages/bsky/src/feature-gates/utils.test.ts b/packages/bsky/src/feature-gates/utils.test.ts new file mode 100644 index 00000000000..61394f6a9bc --- /dev/null +++ b/packages/bsky/src/feature-gates/utils.test.ts @@ -0,0 +1,66 @@ +/// +import { mergeUserContexts, normalizeUserContext } from './utils' + +describe('normalizeUserContext', () => { + it('defaults', () => { + const ctx = normalizeUserContext({}) + expect(ctx.did).toBeUndefined() + expect(ctx.deviceId).toMatch(/^anon-/) + expect(ctx.sessionId).toMatch(/^anon-/) + }) + + it('with did', () => { + const ctx = normalizeUserContext({ + did: 'did:example:123', + }) + expect(ctx.did).toBe('did:example:123') + expect(ctx.deviceId).toBe('did:example:123') + expect(ctx.sessionId).toMatch(/^anon-/) + }) + + it('with did and deviceId', () => { + const ctx = normalizeUserContext({ + did: 'did:example:123', + deviceId: 'device-456', + }) + expect(ctx.did).toBe('did:example:123') + expect(ctx.deviceId).toBe('device-456') + expect(ctx.sessionId).toMatch(/^anon-/) + }) + + it('with only deviceId and sessionId', () => { + const ctx = normalizeUserContext({ + deviceId: 'device-456', + sessionId: 'session-789', + }) + expect(ctx.did).toBeUndefined() + expect(ctx.deviceId).toBe('device-456') + expect(ctx.sessionId).toBe('session-789') + }) +}) + +describe('mergeUserContexts', () => { + it('anonymous base context, override with did', () => { + const base = normalizeUserContext({}) + const merged = mergeUserContexts(base, { did: 'did:example:123' }) + expect(merged.did).toBe('did:example:123') + expect(merged.deviceId).toBe('did:example:123') + expect(merged.sessionId).toBe(base.sessionId) + }) + + it('base context with did, override with different did', () => { + const base = normalizeUserContext({ did: 'did:example:123' }) + const merged = mergeUserContexts(base, { did: 'did:example:456' }) + expect(merged.did).toBe('did:example:456') + expect(merged.deviceId).toBe('did:example:456') + expect(merged.sessionId).toMatch(/^anon-/) + }) + + it('base context with did, override with same did', () => { + const base = normalizeUserContext({ did: 'did:example:123' }) + const merged = mergeUserContexts(base, { did: 'did:example:123' }) + expect(merged.did).toBe('did:example:123') + expect(merged.deviceId).toBe('did:example:123') + expect(merged.sessionId).toBe(base.sessionId) + }) +}) diff --git a/packages/bsky/src/feature-gates/utils.ts b/packages/bsky/src/feature-gates/utils.ts new file mode 100644 index 00000000000..910eeceeaab --- /dev/null +++ b/packages/bsky/src/feature-gates/utils.ts @@ -0,0 +1,131 @@ +import crypto from 'node:crypto' +import { type UserContext as GrowthBookUserContext } from '@growthbook/growthbook' +import { NormalizedUserContext, TrackingMetadata, UserContext } from './types' + +export function normalizeUserContext( + userContext: UserContext, +): NormalizedUserContext { + const did = userContext.did ?? undefined + let deviceId = userContext.deviceId + let sessionId = userContext.sessionId + + if (!deviceId) { + /* + * If we don't have a deviceId by other means, such as a request header, + * fall back to the DID. Our event proxy ensures ordering based on this + * deviceId (also called a stableId in the proxy), so if we have a DID, we + * want to use it to ensure client and server events are properly ordered. + * + * Without any better option for identifying the user, we generate a + * random deviceId. + */ + deviceId = did ?? `anon-${crypto.randomUUID()}` + } + + if (!sessionId) { + /* + * If we don't have a sessionId by other means, such as a request header, + * generate a random sessionId. + */ + sessionId = `anon-${crypto.randomUUID()}` + } + + return { + did, + deviceId, + sessionId, + } +} + +/** + * Merge the base user context with any overrides provided at check time. This + * allows us to set a base context for the request, but also override or add + * properties for specific gate checks if needed. + */ +export function mergeUserContexts( + base: NormalizedUserContext, + overrides?: UserContext, +): NormalizedUserContext { + const did = overrides?.did ?? base.did ?? undefined + let deviceId = overrides?.deviceId ?? base.deviceId + let sessionId = overrides?.sessionId ?? base.sessionId + + let isDifferentDid = false + + if (did && deviceId.startsWith('anon-')) { + /* + * If we have a DID, but the existing deviceId is anonymous, use the DID as + * the deviceId to ensure proper ordering of events in our event proxy. + * This matches the logic in `normalizeUserContext` where we fall back to + * the DID for the deviceId if we don't have a deviceId from other means. + */ + deviceId = did + } else if (did && deviceId !== did) { + /* + * If we have both a DID and a deviceId, but they don't match, we may be + * overriding context to check a feature that is independent of a single + * request handler lifecycle. + * + * Example: a ScopedFeatureGatesClient was created in the root request + * handler with a user context that has a DID, but later on in the request + * lifecycle we may check a gate using the DID of the author of the image + * we're returning as part of the response. + */ + deviceId = did + isDifferentDid = true + } + + if (isDifferentDid) { + /* + * If we're merging in a different DID, we should also generate a new + * sessionId to avoid mixing events from different users under the same + * session. + */ + sessionId = `anon-${crypto.randomUUID()}` + } + + return { + did, + deviceId, + sessionId, + } +} + +/** + * Extract the `UserContext` from GrowthBook's own `UserContext`, which we + * passed into `isOn` as `attributes`. + */ +export function extractUserContextFromGrowthbookUserContext( + userContext: GrowthBookUserContext, +): NormalizedUserContext { + /* + * The values passed to Growthbook already should have been + * `NormalizedUserContext`, but for type safety we run them through the + * normalizer again to ensure we have all the required properties and + * fallbacks in place. + */ + return normalizeUserContext({ + did: userContext.attributes?.did, + deviceId: userContext.attributes?.deviceId, + sessionId: userContext.attributes?.sessionId, + }) +} + +/** + * Convert the `UserContext` into the `TrackingMetadata` format that we + * use for our analytics events. This ensures that we have the same user + * properties as we do for events from our client app. + */ +export function parsedUserContextToTrackingMetadata( + userContext: NormalizedUserContext, +): TrackingMetadata { + return { + base: { + deviceId: userContext.deviceId, + sessionId: userContext.sessionId, + }, + session: { + did: userContext.did ?? undefined, + }, + } +} diff --git a/packages/bsky/src/hydration/actor.ts b/packages/bsky/src/hydration/actor.ts index 3d3dd74290a..3614761855d 100644 --- a/packages/bsky/src/hydration/actor.ts +++ b/packages/bsky/src/hydration/actor.ts @@ -1,29 +1,42 @@ -import { AppBskyNotificationDeclaration } from '@atproto/api' import { mapDefined } from '@atproto/common' +import { + AtIdentifierString, + AtUriString, + DatetimeString, + DidString, + HandleString, + isDidIdentifier, + isHandleIdentifier, + normalizeHandle, +} from '@atproto/syntax' import { DataPlaneClient } from '../data-plane/client' -import { Record as ProfileRecord } from '../lexicon/types/app/bsky/actor/profile' -import { Record as StatusRecord } from '../lexicon/types/app/bsky/actor/status' -import { Record as NotificationDeclarationRecord } from '../lexicon/types/app/bsky/notification/declaration' -import { Record as ChatDeclarationRecord } from '../lexicon/types/chat/bsky/actor/declaration' -import { Record as GermDeclarationRecord } from '../lexicon/types/com/germnetwork/declaration' +import { app, chat, com } from '../lexicons/index.js' import { ActivitySubscription, VerificationMeta } from '../proto/bsky_pb' +import { + ChatDeclarationRecord, + GermDeclarationRecord, + NotificationDeclarationRecord, + ProfileRecord, + StatusRecord, +} from '../views/types.js' import { HydrationMap, RecordInfo, isActivitySubscriptionEnabled, + parseDate, parseRecord, parseString, safeTakedownRef, } from './util' type AllowActivitySubscriptions = Extract< - AppBskyNotificationDeclaration.Record['allowSubscriptions'], + app.bsky.notification.declaration.Main['allowSubscriptions'], 'followers' | 'mutuals' | 'none' > export type Actor = { - did: string - handle?: string + did: DidString + handle?: HandleString profile?: ProfileRecord profileCid?: string profileTakedownRef?: string @@ -44,49 +57,55 @@ export type Actor = { * Debug information for internal development */ debug?: { - pagerank?: number + pagerank?: string accountTags?: string[] profileTags?: string[] - [key: string]: unknown } } export type VerificationHydrationState = { - issuer: string - uri: string - handle: string + issuer: DidString + uri: AtUriString + handle: HandleString displayName: string - createdAt: string + createdAt: DatetimeString } export type VerificationMetaRequired = Required -export type Actors = HydrationMap +export type Actors = HydrationMap export type ChatDeclaration = RecordInfo -export type ChatDeclarations = HydrationMap +export type ChatDeclarations = HydrationMap export type GermDeclaration = RecordInfo -export type GermDeclarations = HydrationMap +export type GermDeclarations = HydrationMap export type NotificationDeclaration = RecordInfo -export type NotificationDeclarations = HydrationMap +export type NotificationDeclarations = HydrationMap< + AtUriString, + NotificationDeclaration +> export type Status = RecordInfo -export type Statuses = HydrationMap +export type Statuses = HydrationMap export type ProfileViewerState = { + did: DidString muted?: boolean - mutedByList?: string - blockedBy?: string - blocking?: string - blockedByList?: string - blockingByList?: string - following?: string - followedBy?: string + mutedByList?: AtUriString + blockedBy?: AtUriString + blocking?: AtUriString + blockedByList?: AtUriString + blockingByList?: AtUriString + following?: AtUriString + followedBy?: AtUriString } -export type ProfileViewerStates = HydrationMap +export type ProfileViewerStates = HydrationMap< + AtIdentifierString, + ProfileViewerState +> type ActivitySubscriptionState = { post: boolean @@ -94,15 +113,19 @@ type ActivitySubscriptionState = { } export type ActivitySubscriptionStates = HydrationMap< + DidString, ActivitySubscriptionState | undefined > type KnownFollowersState = { count: number - followers: string[] + followers: DidString[] } -export type KnownFollowersStates = HydrationMap +export type KnownFollowersStates = HydrationMap< + DidString, + KnownFollowersState | undefined +> export type ProfileAgg = { followers: number @@ -113,7 +136,7 @@ export type ProfileAgg = { starterPacks: number } -export type ProfileAggs = HydrationMap +export type ProfileAggs = HydrationMap export class ActorHydrator { constructor(public dataplane: DataPlaneClient) {} @@ -128,49 +151,56 @@ export class ActorHydrator { } } + /** + * @note handles do not need to be normalized + */ async getDids( - handleOrDids: string[], + handleOrDids: AtIdentifierString[], opts?: { lookupUnidirectional?: boolean }, - ): Promise<(string | undefined)[]> { - const handles = handleOrDids.filter((actor) => !actor.startsWith('did:')) - const res = handles.length - ? await this.dataplane.getDidsByHandles({ - handles, - lookupUnidirectional: opts?.lookupUnidirectional, - }) - : { dids: [] } - const didByHandle = handles.reduce( - (acc, cur, i) => { - const did = res.dids[i] - if (did && did.length > 0) { - return acc.set(cur, did) - } - return acc - }, - new Map() as Map, - ) + ): Promise<(DidString | undefined)[]> { + const didByHandle = new Map() + + const handles = handleOrDids.filter(isHandleIdentifier) + if (handles.length) { + const { dids } = await this.dataplane.getDidsByHandles({ + handles: handles.map(normalizeHandle), + lookupUnidirectional: opts?.lookupUnidirectional, + }) + + for (let i = 0; i < handles.length; i++) { + const did = parseString(dids[i]) + if (did) didByHandle.set(handles[i], did) + } + } + return handleOrDids.map((id) => - id.startsWith('did:') ? id : didByHandle.get(id), + isDidIdentifier(id) ? id : didByHandle.get(id), ) } - async getDidsDefined(handleOrDids: string[]): Promise { + async getDidsDefined( + handleOrDids: AtIdentifierString[], + ): Promise { const res = await this.getDids(handleOrDids) - // @ts-ignore - return res.filter((did) => did !== undefined) + return res.filter((v) => v != null) } async getActors( - dids: string[], + dids: DidString[], opts: { includeTakedowns?: boolean - skipCacheForDids?: string[] + skipCacheForDids?: DidString[] } = {}, ): Promise { + const map: Actors = new HydrationMap() + if (!dids.length) return map + const { includeTakedowns = false, skipCacheForDids } = opts - if (!dids.length) return new HydrationMap() + const res = await this.dataplane.getActors({ dids, skipCacheForDids }) - return dids.reduce((acc, did, i) => { + for (let i = 0; i < dids.length; i++) { + const did = dids[i] + const actor = res.actors[i] const isNoHosted = actor.takenDown || @@ -180,15 +210,21 @@ export class ActorHydrator { (isNoHosted && !includeTakedowns) || !!actor.tombstonedAt ) { - return acc.set(did, null) + map.set(did, null) + continue } const profile = actor.profile?.record - ? parseRecord(actor.profile, includeTakedowns) + ? parseRecord( + app.bsky.actor.profile.main, + actor.profile, + includeTakedowns, + ) : undefined const status = actor.statusRecord - ? parseRecord( + ? parseRecord( + app.bsky.actor.status.main, actor.statusRecord, /* * Always true, we filter this out in the `Views.status()`. If we @@ -199,12 +235,18 @@ export class ActorHydrator { : undefined const germ = actor.germRecord - ? parseRecord(actor.germRecord, includeTakedowns) + ? parseRecord( + com.germnetwork.declaration.main, + actor.germRecord, + includeTakedowns, + ) : undefined const verifications = mapDefined( - Object.entries(actor.verifiedBy), - ([actorDid, verificationMeta]) => { + Object.entries(actor.verifiedBy) as [DidString, VerificationMeta][], + ([actorDid, verificationMeta]): + | VerificationHydrationState + | undefined => { if ( verificationMeta.handle && verificationMeta.rkey && @@ -213,9 +255,11 @@ export class ActorHydrator { return { issuer: actorDid, uri: `at://${actorDid}/app.bsky.graph.verification/${verificationMeta.rkey}`, - handle: verificationMeta.handle, + handle: verificationMeta.handle as HandleString, displayName: verificationMeta.displayName, - createdAt: verificationMeta.sortedAt.toDate().toISOString(), + createdAt: ( + parseDate(verificationMeta.sortedAt) ?? new Date(0) + ).toISOString() as DatetimeString, } } // Filter out the verification meta that doesn't contain all info. @@ -238,14 +282,14 @@ export class ActorHydrator { } const debug = { - pagerank: actor.pagerank, + pagerank: actor.pagerank ? actor.pagerank.toString() : undefined, accountTags: actor.tags, profileTags: actor.profileTags, } - return acc.set(did, { + map.set(did, { did, - handle: parseString(actor.handle), + handle: parseString(actor.handle), profile: profile?.record, profileCid: profile?.cid, profileTakedownRef: profile?.takedownRef, @@ -255,7 +299,7 @@ export class ActorHydrator { isLabeler: actor.labeler ?? false, allowIncomingChatsFrom: actor.allowIncomingChatsFrom || undefined, upstreamStatus: actor.upstreamStatus || undefined, - createdAt: actor.createdAt?.toDate(), + createdAt: parseDate(actor.createdAt), priorityNotifications: actor.priorityNotifications, trustedVerifier: actor.trustedVerifier, verifications, @@ -266,162 +310,258 @@ export class ActorHydrator { ), debug, }) - }, new HydrationMap()) + } + + return map } async getChatDeclarations( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { - if (!uris.length) return new HydrationMap() + const map: ChatDeclarations = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getActorChatDeclarationRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + const record = parseRecord( + chat.bsky.actor.declaration.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uri, record ?? null) + } + + return map } async getGermDeclarations( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { - if (!uris.length) return new HydrationMap() + const map: GermDeclarations = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getGermDeclarationRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + com.germnetwork.declaration.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uris[i], record ?? null) + } + + return map } async getNotificationDeclarations( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { - if (!uris.length) return new HydrationMap() - const res = await this.dataplane.getNotificationDeclarationRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + const map: NotificationDeclarations = new HydrationMap() + if (!uris.length) return map + + const res = await this.dataplane.getNotificationDeclarationRecords({ + uris, + }) + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + const record = parseRecord( + app.bsky.notification.declaration.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uri, record ?? null) + } + + return map } - async getStatus(uris: string[], includeTakedowns = false): Promise { - if (!uris.length) return new HydrationMap() + async getStatus( + uris: AtUriString[], + includeTakedowns = false, + ): Promise { + const map: Statuses = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getStatusRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord(res.records[i], includeTakedowns) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + const record = parseRecord( + app.bsky.actor.status.main, + res.records[i], + includeTakedowns, + ) + map.set(uri, record ?? null) + } + + return map } // "naive" because this method does not verify the existence of the list itself // a later check in the main hydrator will remove list uris that have been deleted or // repurposed to "curate lists" async getProfileViewerStatesNaive( - dids: string[], - viewer: string, + actors: AtIdentifierString[], + viewer: DidString, ): Promise { - if (!dids.length) return new HydrationMap() + const map: ProfileViewerStates = new HydrationMap() + if (!actors.length) return map + + // @TODO we could use "await this.getDids(actors)" here to resolve the + // handles (no other code change should be needed). This was not done as + // part of this PR to avoid changing the behavior of this method. + const actorDids = actors.map((a) => (isDidIdentifier(a) ? a : undefined)) + + // getRelationships requires DidString so we remove anything that isn't one + const actorDidsDefined = Array.from( + new Set( + actorDids + .filter((did) => did != null) + // Since we special case self-relationship below, we can skip querying + // the dataplane for the viewer's own DID if it's included in the + // input. + .filter((did) => did !== viewer), + ), + ) + const res = await this.dataplane.getRelationships({ actorDid: viewer, - targetDids: dids, + targetDids: actorDidsDefined, }) - return dids.reduce((acc, did, i) => { - const rels = res.relationships[i] - if (viewer === did) { + const actorToDid = new Map( + actors.map((actor, i) => [actor, actorDids[i]]), + ) + + for (let i = 0; i < actors.length; i++) { + const actor = actors[i] + + const did = actorToDid.get(actor) + // ignore unresolved handles + if (!did) continue + + if (did === viewer) { // ignore self-follows, self-mutes, self-blocks, self-activity-subscriptions - return acc.set(did, {}) + map.set(actor, { did }) + continue } - return acc.set(did, { + + // Get the index that was used to query the relationships for this actor + const index = actorDidsDefined.indexOf(did) + if (index === -1) continue + + const rels = res.relationships[index] + map.set(actor, { + did, muted: rels.muted ?? false, mutedByList: parseString(rels.mutedByList), blockedBy: parseString(rels.blockedBy), - blocking: parseString(rels.blocking), + blocking: parseString(rels.blocking), blockedByList: parseString(rels.blockedByList), blockingByList: parseString(rels.blockingByList), - following: parseString(rels.following), + following: parseString(rels.following), followedBy: parseString(rels.followedBy), }) - }, new HydrationMap()) + } + + return map } async getKnownFollowers( - dids: string[], - viewer: string | null, + dids: DidString[], + viewer: DidString | null, ): Promise { - if (!viewer) return new HydrationMap() - const { results: knownFollowersResults } = await this.dataplane - .getFollowsFollowing( - { - actorDid: viewer, - targetDids: dids, - }, - { - signal: AbortSignal.timeout(100), - }, - ) - .catch(() => ({ results: [] })) - return dids.reduce((acc, did, i) => { - const result = knownFollowersResults[i]?.dids - return acc.set( - did, - result && result.length > 0 - ? { - count: result.length, - followers: result.slice(0, 5), - } - : undefined, - ) - }, new HydrationMap()) + const map: KnownFollowersStates = new HydrationMap() + if (!viewer) return map + if (!dids.length) return map + + try { + const { results: knownFollowersResults } = + await this.dataplane.getFollowsFollowing( + { + actorDid: viewer, + targetDids: dids, + }, + { + signal: AbortSignal.timeout(100), + }, + ) + + for (let i = 0; i < dids.length; i++) { + const did = dids[i] + + const result = knownFollowersResults[i]?.dids + + map.set( + did, + result && result.length > 0 + ? { + count: result.length, + followers: result.slice(0, 5) as DidString[], + } + : undefined, + ) + } + } catch { + // ignore errors and return empty map + } + + return map } async getActivitySubscriptions( - dids: string[], - viewer: string | null, + dids: DidString[], + viewer: DidString | null, ): Promise { - if (!viewer) { - return new HydrationMap() - } + const map: ActivitySubscriptionStates = new HydrationMap() + if (!viewer) return map + if (!dids.length) return map - const activitySubscription = (val: ActivitySubscription | undefined) => { - if (!val) return undefined + try { + const { subscriptions } = + await this.dataplane.getActivitySubscriptionsByActorAndSubjects( + { actorDid: viewer, subjectDids: dids }, + { signal: AbortSignal.timeout(100) }, + ) + + for (let i = 0; i < dids.length; i++) { + // @NOTE Although not typed as nullable, the code here used to defend + // against potentially missing subscription objects in the response, so + // we keep that defense in place. + const subscription = subscriptions[i] as + | ActivitySubscription + | undefined + + const state = { + post: subscription?.post != null, + reply: subscription?.reply != null, + } - const result = { - post: !!val.post, - reply: !!val.reply, + const did = dids[i]! + if (isActivitySubscriptionEnabled(state)) { + map.set(did, state) + } else { + map.set(did, undefined) + } } - if (!isActivitySubscriptionEnabled(result)) return undefined - - return result + } catch { + // ignore errors and return empty map } - const { subscriptions } = await this.dataplane - .getActivitySubscriptionsByActorAndSubjects( - { actorDid: viewer, subjectDids: dids }, - { signal: AbortSignal.timeout(100) }, - ) - .catch(() => ({ subscriptions: [] })) - - return dids.reduce((acc, did, i) => { - return acc.set(did, activitySubscription(subscriptions[i])) - }, new HydrationMap()) + return map } - async getProfileAggregates(dids: string[]): Promise { - if (!dids.length) return new HydrationMap() + async getProfileAggregates(dids: DidString[]): Promise { + const map: ProfileAggs = new HydrationMap() + if (!dids.length) return map + const counts = await this.dataplane.getCountsForUsers({ dids }) - return dids.reduce((acc, did, i) => { - return acc.set(did, { + for (let i = 0; i < dids.length; i++) { + const did = dids[i] + map.set(did, { followers: counts.followers[i] ?? 0, follows: counts.following[i] ?? 0, posts: counts.posts[i] ?? 0, @@ -429,6 +569,8 @@ export class ActorHydrator { feeds: counts.feeds[i] ?? 0, starterPacks: counts.starterPacks[i] ?? 0, }) - }, new HydrationMap()) + } + + return map } } diff --git a/packages/bsky/src/hydration/feed.ts b/packages/bsky/src/hydration/feed.ts index dfaf5ab9df0..153f9806af3 100644 --- a/packages/bsky/src/hydration/feed.ts +++ b/packages/bsky/src/hydration/feed.ts @@ -1,16 +1,20 @@ import { dedupeStrs } from '@atproto/common' +import { AtUriString, DidString } from '@atproto/syntax' import { DataPlaneClient } from '../data-plane/client' -import { Record as FeedGenRecord } from '../lexicon/types/app/bsky/feed/generator' -import { Record as LikeRecord } from '../lexicon/types/app/bsky/feed/like' -import { Record as PostRecord } from '../lexicon/types/app/bsky/feed/post' -import { Record as PostgateRecord } from '../lexicon/types/app/bsky/feed/postgate' -import { Record as RepostRecord } from '../lexicon/types/app/bsky/feed/repost' -import { Record as ThreadgateRecord } from '../lexicon/types/app/bsky/feed/threadgate' +import { app } from '../lexicons/index.js' import { postUriToPostgateUri, postUriToThreadgateUri, uriToDid as didFromUri, } from '../util/uris' +import { + FeedGenRecord, + GateRecord, + LikeRecord, + PostRecord, + PostgateRecord, + RepostRecord, +} from '../views/types.js' import { HydrationMap, ItemRef, @@ -31,26 +35,26 @@ export type Post = RecordInfo & { */ debug?: { tags?: string[] - [key: string]: unknown } } -export type Posts = HydrationMap + +export type Posts = HydrationMap export type PostViewerState = { - like?: string - repost?: string + like?: AtUriString + repost?: AtUriString bookmarked?: boolean threadMuted?: boolean } -export type PostViewerStates = HydrationMap +export type PostViewerStates = HydrationMap export type ThreadContext = { // Whether the root author has liked the post. - like?: string + like?: AtUriString } -export type ThreadContexts = HydrationMap +export type ThreadContexts = HydrationMap export type PostAgg = { likes: number @@ -60,35 +64,35 @@ export type PostAgg = { bookmarks: number } -export type PostAggs = HydrationMap +export type PostAggs = HydrationMap export type Like = RecordInfo -export type Likes = HydrationMap +export type Likes = HydrationMap export type Repost = RecordInfo -export type Reposts = HydrationMap +export type Reposts = HydrationMap export type FeedGenAgg = { likes: number } -export type FeedGenAggs = HydrationMap +export type FeedGenAggs = HydrationMap export type FeedGen = RecordInfo -export type FeedGens = HydrationMap +export type FeedGens = HydrationMap export type FeedGenViewerState = { - like?: string + like?: AtUriString } -export type FeedGenViewerStates = HydrationMap +export type FeedGenViewerStates = HydrationMap -export type Threadgate = RecordInfo -export type Threadgates = HydrationMap +export type Threadgate = RecordInfo +export type Threadgates = HydrationMap export type Postgate = RecordInfo -export type Postgates = HydrationMap +export type Postgates = HydrationMap -export type ThreadRef = ItemRef & { threadRoot: string } +export type ThreadRef = ItemRef & { threadRoot: AtUriString } // @NOTE the feed item types in the protos for author feeds and timelines // technically have additional fields, not supported by the mock dataplane. @@ -110,60 +114,72 @@ export class FeedHydrator { constructor(public dataplane: DataPlaneClient) {} async getPosts( - uris: string[], + uris: AtUriString[], includeTakedowns = false, - given = new HydrationMap(), + given: Posts = new HydrationMap(), viewer?: string | null, options: GetPostsHydrationOptions = {}, ): Promise { const [have, need] = split(uris, (uri) => given.has(uri)) - const base = have.reduce( - (acc, uri) => acc.set(uri, given.get(uri) ?? null), - new HydrationMap(), - ) - if (!need.length) return base - const res = await this.dataplane.getPostRecords( - options.processDynamicTagsForView - ? { - uris: need, - viewerDid: viewer ?? undefined, - processDynamicTagsForView: options.processDynamicTagsForView, - } - : { - uris: need, - }, - ) - return need.reduce((acc, uri, i) => { - const record = parseRecord(res.records[i], includeTakedowns) - const violatesThreadGate = res.meta[i].violatesThreadGate - const violatesEmbeddingRules = res.meta[i].violatesEmbeddingRules - const hasThreadGate = res.meta[i].hasThreadGate - const hasPostGate = res.meta[i].hasPostGate - const tags = new Set(res.records[i].tags ?? []) - const debug = { tags: Array.from(tags) } - return acc.set( - uri, - record + const base: Posts = new HydrationMap() + + for (const uri of have) { + base.set(uri, given.get(uri) ?? null) + } + + if (need.length) { + const res = await this.dataplane.getPostRecords( + options.processDynamicTagsForView ? { - ...record, - violatesThreadGate, - violatesEmbeddingRules, - hasThreadGate, - hasPostGate, - tags, - debug, + uris: need, + viewerDid: viewer ?? undefined, + processDynamicTagsForView: options.processDynamicTagsForView, } - : null, + : { + uris: need, + }, ) - }, base) + + for (let i = 0; i < need.length; i++) { + const record = parseRecord( + app.bsky.feed.post.main, + res.records[i], + includeTakedowns, + ) + const violatesThreadGate = res.meta[i].violatesThreadGate + const violatesEmbeddingRules = res.meta[i].violatesEmbeddingRules + const hasThreadGate = res.meta[i].hasThreadGate + const hasPostGate = res.meta[i].hasPostGate + const tags = new Set(res.records[i].tags ?? []) + const debug = { tags: Array.from(tags) } + + base.set( + need[i], + record + ? { + ...record, + violatesThreadGate, + violatesEmbeddingRules, + hasThreadGate, + hasPostGate, + tags, + debug, + } + : null, + ) + } + } + + return base } async getPostViewerStates( refs: ThreadRef[], viewer: string, ): Promise { - if (!refs.length) return new HydrationMap() - const threadRoots = refs.map((r) => r.threadRoot) + const map: PostViewerStates = new HydrationMap() + if (!refs.length) return map + const [likes, reposts, bookmarks, threadMutesMap] = await Promise.all([ this.dataplane.getLikesByActorAndSubjects({ actorDid: viewer, @@ -177,10 +193,15 @@ export class FeedHydrator { actorDid: viewer, uris: refs.map((r) => r.uri), }), - this.getThreadMutes(threadRoots, viewer), + this.getThreadMutes( + refs.map((r) => r.threadRoot), + viewer, + ), ]) - return refs.reduce((acc, { uri, threadRoot }, i) => { - return acc.set(uri, { + + for (let i = 0; i < refs.length; i++) { + const { uri, threadRoot } = refs[i] + map.set(uri, { like: parseString(likes.uris[i]), repost: parseString(reposts.uris[i]), // @NOTE: The dataplane contract is that the array position will be present, @@ -188,11 +209,13 @@ export class FeedHydrator { bookmarked: !!bookmarks.bookmarks.at(i)?.ref?.key, threadMuted: threadMutesMap.get(threadRoot) ?? false, }) - }, new HydrationMap()) + } + + return map } private async getThreadMutes( - threadRoots: string[], + threadRoots: AtUriString[], viewer: string, ): Promise> { const deduped = dedupeStrs(threadRoots) @@ -200,178 +223,234 @@ export class FeedHydrator { actorDid: viewer, threadRoots: deduped, }) - return deduped.reduce((acc, cur, i) => { - return acc.set(cur, threadMutes.muted[i] ?? false) - }, new Map()) + const map: Map = new Map() + for (let i = 0; i < deduped.length; i++) { + map.set(deduped[i], threadMutes.muted[i] ?? false) + } + + return map } async getThreadContexts(refs: ThreadRef[]): Promise { - if (!refs.length) return new HydrationMap() + const map: ThreadContexts = new HydrationMap() + if (!refs.length) return map + + const refsByRootAuthor = new Map() - const refsByRootAuthor = refs.reduce((acc, ref) => { + for (const ref of refs) { const { threadRoot } = ref const rootAuthor = didFromUri(threadRoot) - const existingValue = acc.get(rootAuthor) ?? [] - return acc.set(rootAuthor, [...existingValue, ref]) - }, new Map()) + const existingValue = refsByRootAuthor.get(rootAuthor) ?? [] + refsByRootAuthor.set(rootAuthor, [...existingValue, ref]) + } + const refsByRootAuthorEntries = Array.from(refsByRootAuthor.entries()) - const likesPromises = refsByRootAuthorEntries.map( - ([rootAuthor, refsForAuthor]) => + const rootAuthorsLikes = await Promise.all( + refsByRootAuthorEntries.map(([rootAuthor, refsForAuthor]) => this.dataplane.getLikesByActorAndSubjects({ actorDid: rootAuthor, refs: refsForAuthor.map(({ uri, cid }) => ({ uri, cid })), }), + ), ) - const rootAuthorsLikes = await Promise.all(likesPromises) - - const likesByUri = refsByRootAuthorEntries.reduce( - (acc, [_rootAuthor, refsForAuthor], i) => { - const likesForRootAuthor = rootAuthorsLikes[i] - refsForAuthor.forEach(({ uri }, j) => { - acc.set(uri, likesForRootAuthor.uris[j]) - }) - return acc - }, - new Map(), - ) + const likesByUri = new Map() + + for (let i = 0; i < refsByRootAuthorEntries.length; i++) { + const [_rootAuthor, refsForAuthor] = refsByRootAuthorEntries[i] + const likesForRootAuthor = rootAuthorsLikes[i] + for (let j = 0; j < refsForAuthor.length; j++) { + const { uri } = refsForAuthor[j] + likesByUri.set(uri, likesForRootAuthor.uris[j] as AtUriString) + } + } - return refs.reduce((acc, { uri }) => { - return acc.set(uri, { + for (const { uri } of refs) { + map.set(uri, { like: parseString(likesByUri.get(uri)), }) - }, new HydrationMap()) + } + + return map } async getPostAggregates( refs: ItemRef[], - viewer: string | null, + viewer: DidString | null, ): Promise { - if (!refs.length) return new HydrationMap() + const map: PostAggs = new HydrationMap() + if (!refs.length) map + const counts = await this.dataplane.getInteractionCounts({ refs, skipCacheForDids: viewer ? [viewer] : undefined, }) - return refs.reduce((acc, { uri }, i) => { - return acc.set(uri, { + + for (let i = 0; i < refs.length; i++) { + map.set(refs[i].uri, { likes: counts.likes[i] ?? 0, - reposts: counts.reposts[i] ?? 0, replies: counts.replies[i] ?? 0, + reposts: counts.reposts[i] ?? 0, quotes: counts.quotes[i] ?? 0, bookmarks: counts.bookmarks[i] ?? 0, }) - }, new HydrationMap()) + } + + return map } async getFeedGens( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { - if (!uris.length) return new HydrationMap() + const map: FeedGens = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getFeedGeneratorRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + app.bsky.feed.generator.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uris[i], record ?? null) + } + + return map } async getFeedGenViewerStates( - uris: string[], - viewer: string, + uris: AtUriString[], + viewer: DidString, ): Promise { - if (!uris.length) return new HydrationMap() + const map: FeedGenViewerStates = new HydrationMap() + if (!uris.length) return map + const likes = await this.dataplane.getLikesByActorAndSubjects({ actorDid: viewer, refs: uris.map((uri) => ({ uri })), }) - return uris.reduce((acc, uri, i) => { - return acc.set(uri, { + for (let i = 0; i < uris.length; i++) { + map.set(uris[i], { like: parseString(likes.uris[i]), }) - }, new HydrationMap()) + } + + return map } async getFeedGenAggregates( refs: ItemRef[], - viewer: string | null, + viewer: DidString | null, ): Promise { - if (!refs.length) return new HydrationMap() + const map: FeedGenAggs = new HydrationMap() + if (!refs.length) return map + const counts = await this.dataplane.getInteractionCounts({ refs, skipCacheForDids: viewer ? [viewer] : undefined, }) - return refs.reduce((acc, { uri }, i) => { - return acc.set(uri, { - likes: counts.likes[i] ?? 0, - }) - }, new HydrationMap()) + for (let i = 0; i < refs.length; i++) { + map.set(refs[i].uri, { likes: counts.likes[i] ?? 0 }) + } + + return map } async getThreadgatesForPosts( - postUris: string[], + postUris: AtUriString[], includeTakedowns = false, ): Promise { - if (!postUris.length) return new HydrationMap() const uris = postUris.map(postUriToThreadgateUri) return this.getThreadgateRecords(uris, includeTakedowns) } async getThreadgateRecords( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { + const map: Threadgates = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getThreadGateRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + app.bsky.feed.threadgate.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uris[i], record ?? null) + } + + return map } async getPostgatesForPosts( - postUris: string[], + postUris: AtUriString[], includeTakedowns = false, ): Promise { - if (!postUris.length) return new HydrationMap() const uris = postUris.map(postUriToPostgateUri) return this.getPostgateRecords(uris, includeTakedowns) } async getPostgateRecords( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { + const map: Postgates = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getPostgateRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + app.bsky.feed.postgate.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uris[i], record ?? null) + } + + return map } - async getLikes(uris: string[], includeTakedowns = false): Promise { - if (!uris.length) return new HydrationMap() + async getLikes( + uris: AtUriString[], + includeTakedowns = false, + ): Promise { + const map: Likes = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getLikeRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord(res.records[i], includeTakedowns) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + app.bsky.feed.like.main, + res.records[i], + includeTakedowns, + ) + map.set(uris[i], record ?? null) + } + + return map } - async getReposts(uris: string[], includeTakedowns = false): Promise { - if (!uris.length) return new HydrationMap() + async getReposts( + uris: AtUriString[], + includeTakedowns = false, + ): Promise { + const map: Reposts = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getRepostRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord(res.records[i], includeTakedowns) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + app.bsky.feed.repost.main, + res.records[i], + includeTakedowns, + ) + map.set(uris[i], record ?? null) + } + + return map } } diff --git a/packages/bsky/src/hydration/graph.ts b/packages/bsky/src/hydration/graph.ts index 91f4b70d846..5d1ff40b540 100644 --- a/packages/bsky/src/hydration/graph.ts +++ b/packages/bsky/src/hydration/graph.ts @@ -1,61 +1,67 @@ +import { AtUriString, DidString } from '@atproto/syntax' import { DataPlaneClient } from '../data-plane/client' -import { Record as BlockRecord } from '../lexicon/types/app/bsky/graph/block' -import { Record as FollowRecord } from '../lexicon/types/app/bsky/graph/follow' -import { Record as ListRecord } from '../lexicon/types/app/bsky/graph/list' -import { Record as ListItemRecord } from '../lexicon/types/app/bsky/graph/listitem' -import { Record as StarterPackRecord } from '../lexicon/types/app/bsky/graph/starterpack' -import { Record as VerificationRecord } from '../lexicon/types/app/bsky/graph/verification' +import { app } from '../lexicons/index.js' import { FollowInfo } from '../proto/bsky_pb' +import { + BlockRecord, + FollowRecord, + ListItemRecord, + ListRecord, + StarterPackRecord, + VerificationRecord, +} from '../views/types.js' import { HydrationMap, ItemRef, RecordInfo, parseRecord } from './util' export type List = RecordInfo -export type Lists = HydrationMap +export type Lists = HydrationMap export type ListItem = RecordInfo -export type ListItems = HydrationMap +export type ListItems = HydrationMap export type ListViewerState = { - viewerMuted?: string - viewerListBlockUri?: string - viewerInList?: string + viewerMuted?: string // @TODO AtUriString ? + viewerListBlockUri?: AtUriString + viewerInList?: string // @TODO AtUriString ? } -export type ListViewerStates = HydrationMap +export type ListViewerStates = HydrationMap export type ListMembershipState = { - actorListItemUri?: string + actorListItemUri?: AtUriString } + // list uri => actor did => state export type ListMembershipStates = HydrationMap< - HydrationMap + AtUriString, + HydrationMap > export type Follow = RecordInfo -export type Follows = HydrationMap +export type Follows = HydrationMap export type Block = RecordInfo export type StarterPack = RecordInfo -export type StarterPacks = HydrationMap +export type StarterPacks = HydrationMap export type Verification = RecordInfo -export type Verifications = HydrationMap +export type Verifications = HydrationMap export type StarterPackAgg = { joinedWeek: number joinedAllTime: number - listItemSampleUris?: string[] // gets set during starter pack hydration (not for basic view) + listItemSampleUris?: AtUriString[] // gets set during starter pack hydration (not for basic view) } -export type StarterPackAggs = HydrationMap +export type StarterPackAggs = HydrationMap export type ListAgg = { listItems: number } -export type ListAggs = HydrationMap +export type ListAggs = HydrationMap -export type RelationshipPair = [didA: string, didB: string] +export type RelationshipPair = [didA: DidString, didB: DidString] const dedupePairs = (pairs: RelationshipPair[]): RelationshipPair[] => { const deduped = pairs.reduce((acc, pair) => { @@ -94,42 +100,60 @@ export class Blocks { // No "blocking" vs. "blocked" directionality: only suitable for bidirectional block checks export type BlockEntry = { - blockUri: string | undefined - blockListUri: string | undefined + blockUri: AtUriString | undefined + blockListUri: AtUriString | undefined } export class GraphHydrator { constructor(public dataplane: DataPlaneClient) {} - async getLists(uris: string[], includeTakedowns = false): Promise { - if (!uris.length) return new HydrationMap() + async getLists( + uris: AtUriString[], + includeTakedowns = false, + ): Promise { + const map: Lists = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getListRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord(res.records[i], includeTakedowns) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + app.bsky.graph.list.main, + res.records[i], + includeTakedowns, + ) + map.set(uris[i], record ?? null) + } + + return map } async getListItems( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { - if (!uris.length) return new HydrationMap() + const map: ListItems = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getListItemRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const record = parseRecord( + app.bsky.graph.listitem.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uris[i], record ?? null) + } + + return map } async getListViewerStates( - uris: string[], + uris: AtUriString[], viewer: string, ): Promise { - if (!uris.length) return new HydrationMap() + const map: ListViewerStates = new HydrationMap() + if (!uris.length) return map + const mutesAndBlocks = await Promise.all( uris.map((uri) => this.getMutesAndBlocks(uri, viewer)), ) @@ -137,13 +161,16 @@ export class GraphHydrator { actorDid: viewer, listUris: uris, }) - return uris.reduce((acc, uri, i) => { - return acc.set(uri, { + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + map.set(uri, { viewerMuted: mutesAndBlocks[i].muted ? uri : undefined, viewerListBlockUri: mutesAndBlocks[i].listBlockUri || undefined, viewerInList: listMemberships.listitemUris[i], }) - }, new HydrationMap()) + } + + return map } private async getMutesAndBlocks(uri: string, viewer: string) { @@ -159,7 +186,7 @@ export class GraphHydrator { ]) return { muted: muted.subscribed, - listBlockUri: listBlockUri.listblockUri, + listBlockUri: listBlockUri.listblockUri as AtUriString, } } @@ -172,51 +199,82 @@ export class GraphHydrator { const pair = deduped[i] const block = res.blocks[i] blocks.set(pair.a, pair.b, { - blockUri: block.blockedBy || block.blocking || undefined, - blockListUri: block.blockedByList || block.blockingByList || undefined, + blockUri: (block.blockedBy || block.blocking || undefined) as + | AtUriString + | undefined, + blockListUri: (block.blockedByList || + block.blockingByList || + undefined) as AtUriString | undefined, }) } return blocks } - async getFollows(uris: string[], includeTakedowns = false): Promise { - if (!uris.length) return new HydrationMap() + async getFollows( + uris: AtUriString[], + includeTakedowns = false, + ): Promise { + const map: Follows = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getFollowRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord(res.records[i], includeTakedowns) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + const record = parseRecord( + app.bsky.graph.follow.main, + res.records[i], + includeTakedowns, + ) + map.set(uri, record ?? null) + } + + return map } async getVerifications( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { - if (!uris.length) return new HydrationMap() + const map: Verifications = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getVerificationRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + const record = parseRecord( + app.bsky.graph.verification.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uri, record ?? null) + } + + return map } async getBlocks( - uris: string[], + uris: AtUriString[], includeTakedowns = false, - ): Promise> { - if (!uris.length) return new HydrationMap() + ): Promise> { + const map = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getBlockRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord(res.records[i], includeTakedowns) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + const record = parseRecord( + app.bsky.graph.block.main, + res.records[i], + includeTakedowns, + ) + map.set(uri, record ?? null) + } + + return map } async getActorFollows(input: { - did: string + did: DidString cursor?: string limit?: number }): Promise<{ follows: FollowInfo[]; cursor: string }> { @@ -230,7 +288,7 @@ export class GraphHydrator { } async getActorFollowers(input: { - did: string + did: DidString cursor?: string limit?: number }): Promise<{ followers: FollowInfo[]; cursor: string }> { @@ -244,38 +302,53 @@ export class GraphHydrator { } async getStarterPacks( - uris: string[], + uris: AtUriString[], includeTakedowns = false, ): Promise { - if (!uris.length) return new HydrationMap() + const map: StarterPacks = new HydrationMap() + if (!uris.length) return map + const res = await this.dataplane.getStarterPackRecords({ uris }) - return uris.reduce((acc, uri, i) => { - const record = parseRecord( + for (let i = 0; i < uris.length; i++) { + const uri = uris[i] + const record = parseRecord( + app.bsky.graph.starterpack.main, res.records[i], includeTakedowns, ) - return acc.set(uri, record ?? null) - }, new HydrationMap()) + map.set(uri, record ?? null) + } + + return map } async getStarterPackAggregates(refs: ItemRef[]) { - if (!refs.length) return new HydrationMap() - const counts = await this.dataplane.getStarterPackCounts({ refs }) - return refs.reduce((acc, { uri }, i) => { - return acc.set(uri, { - joinedWeek: counts.joinedWeek[i] ?? 0, - joinedAllTime: counts.joinedAllTime[i] ?? 0, - }) - }, new HydrationMap()) + const map: StarterPackAggs = new HydrationMap() + + if (refs.length) { + const counts = await this.dataplane.getStarterPackCounts({ refs }) + for (let i = 0; i < refs.length; i++) { + map.set(refs[i].uri, { + joinedWeek: counts.joinedWeek[i] ?? 0, + joinedAllTime: counts.joinedAllTime[i] ?? 0, + }) + } + } + + return map } - async getListAggregates(refs: ItemRef[]) { - if (!refs.length) return new HydrationMap() - const counts = await this.dataplane.getListCounts({ refs }) - return refs.reduce((acc, { uri }, i) => { - return acc.set(uri, { - listItems: counts.listItems[i] ?? 0, - }) - }, new HydrationMap()) + async getListAggregates(refs: ItemRef[]): Promise { + const map: ListAggs = new HydrationMap() + + if (refs.length) { + const counts = await this.dataplane.getListCounts({ refs }) + for (let i = 0; i < refs.length; i++) { + map.set(refs[i].uri, { + listItems: counts.listItems[i] ?? 0, + }) + } + } + return map } } diff --git a/packages/bsky/src/hydration/hydrator.ts b/packages/bsky/src/hydration/hydrator.ts index d4311a4c17b..6bfb397235f 100644 --- a/packages/bsky/src/hydration/hydrator.ts +++ b/packages/bsky/src/hydration/hydrator.ts @@ -1,22 +1,24 @@ import assert from 'node:assert' import { mapDefined } from '@atproto/common' -import { AtUri } from '@atproto/syntax' +import { AtUri, AtUriString, DidString, UriString } from '@atproto/syntax' import { DataPlaneClient } from '../data-plane/client' -import { type CheckedFeatureGatesMap, FeatureGateID } from '../feature-gates' -import { ids } from '../lexicon/lexicons' -import { Record as ProfileRecord } from '../lexicon/types/app/bsky/actor/profile' -import { isMain as isEmbedRecord } from '../lexicon/types/app/bsky/embed/record' -import { isMain as isEmbedRecordWithMedia } from '../lexicon/types/app/bsky/embed/recordWithMedia' -import { isListRule as isThreadgateListRule } from '../lexicon/types/app/bsky/feed/threadgate' +import { FeatureGatesClient, ScopedFeatureGatesClient } from '../feature-gates' +import { app, chat, com } from '../lexicons/index.js' import { hydrationLogger } from '../logger' import { - Bookmark, + Bookmark as BookmarkLex, BookmarkInfo, Notification, RecordRef, } from '../proto/bsky_pb' import { ParsedLabelers } from '../util' import { uriToDid, uriToDid as didFromUri } from '../util/uris' +import { + ProfileRecord, + isListRuleType, + isRecordEmbedType, + isRecordWithMediaType, +} from '../views/types.js' import { ActivitySubscriptionStates, ActorHydrator, @@ -73,6 +75,7 @@ import { mergeManyMaps, mergeMaps, mergeNestedMaps, + parseDate, urisByCollection, } from './util' @@ -82,8 +85,9 @@ export class HydrateCtx { includeTakedowns = this.vals.includeTakedowns overrideIncludeTakedownsForActor = this.vals.overrideIncludeTakedownsForActor include3pBlocks = this.vals.include3pBlocks + skipViewerBlocks = this.vals.skipViewerBlocks includeDebugField = this.vals.includeDebugField - featureGates: CheckedFeatureGatesMap = this.vals.featureGates || new Map() + features = this.vals.features constructor(private vals: HydrateCtxVals) {} // Convenience with use with dataplane.getActors cache control get skipCacheForViewer() { @@ -95,14 +99,17 @@ export class HydrateCtx { } } +export type HydrateCtxWithViewer = HydrateCtx & { viewer: string } + export type HydrateCtxVals = { labelers: ParsedLabelers - viewer: string | null + viewer: DidString | null includeTakedowns?: boolean overrideIncludeTakedownsForActor?: boolean include3pBlocks?: boolean + skipViewerBlocks?: boolean includeDebugField?: boolean - featureGates?: CheckedFeatureGatesMap + features: ScopedFeatureGatesClient } export type HydrationState = { @@ -144,7 +151,7 @@ export type HydrationState = { } export type PostBlock = { embed: boolean; parent: boolean; root: boolean } -export type PostBlocks = HydrationMap +export type PostBlocks = HydrationMap type PostBlockPairs = { embed?: RelationshipPair parent?: RelationshipPair @@ -152,15 +159,25 @@ type PostBlockPairs = { } export type LikeBlock = boolean -export type LikeBlocks = HydrationMap +export type LikeBlocks = HydrationMap export type FollowBlock = boolean -export type FollowBlocks = HydrationMap - -export type BidirectionalBlocks = HydrationMap> +export type FollowBlocks = HydrationMap + +export type BidirectionalBlocks = HydrationMap< + DidString, + HydrationMap +> + +export type Bookmark = { + ref?: { key: string } + subjectUri: string + subjectCid: string + indexedAt?: Date +} // actor DID -> stash key -> bookmark -export type Bookmarks = HydrationMap> +export type Bookmarks = HydrationMap> /** * Additional config passed from `ServerConfig` to the `Hydrator` instance. @@ -168,6 +185,7 @@ export type Bookmarks = HydrationMap> */ export type HydratorConfig = { debugFieldAllowedDids: Set + featureGatesClient: FeatureGatesClient } export class Hydrator { @@ -180,7 +198,7 @@ export class Hydrator { constructor( public dataplane: DataPlaneClient, - serviceLabelers: string[] = [], + serviceLabelers: readonly DidString[] = [], config: HydratorConfig, ) { this.config = config @@ -196,7 +214,7 @@ export class Hydrator { // - list basic // Note: builds on the naive profile viewer hydrator and removes references to lists that have been deleted async hydrateProfileViewers( - dids: string[], + dids: DidString[], ctx: HydrateCtx, ): Promise { const viewer = ctx.viewer @@ -205,7 +223,7 @@ export class Hydrator { dids, viewer, ) - const listUris: string[] = [] + const listUris: AtUriString[] = [] profileViewers.forEach((item) => { listUris.push(...listUrisFromProfileViewer(item)) }) @@ -225,7 +243,7 @@ export class Hydrator { // - profile // - list basic async hydrateProfiles( - dids: string[], + dids: DidString[], ctx: HydrateCtx, ): Promise { /** @@ -257,7 +275,7 @@ export class Hydrator { // - profile // - list basic async hydrateProfilesBasic( - dids: string[], + dids: DidString[], ctx: HydrateCtx, ): Promise { return this.hydrateProfiles(dids, ctx) @@ -272,7 +290,7 @@ export class Hydrator { // - list basic // - labels async hydrateProfilesDetailed( - dids: string[], + dids: DidString[], ctx: HydrateCtx, ): Promise { let knownFollowers: KnownFollowersStates = new HydrationMap() @@ -298,15 +316,13 @@ export class Hydrator { ) } - const subjectsToKnownFollowersMap = Array.from( - knownFollowers.keys(), - ).reduce((acc, did) => { + const subjectsToKnownFollowersMap = new Map() + + for (const did of knownFollowers.keys()) { const known = knownFollowers.get(did) - if (known) { - acc.set(did, known.followers) - } - return acc - }, new Map()) + if (known) subjectsToKnownFollowersMap.set(did, known.followers) + } + const allKnownFollowerDids = Array.from(knownFollowers.values()) .filter(Boolean) .flatMap((f) => f!.followers) @@ -316,7 +332,7 @@ export class Hydrator { this.actor.getProfileAggregates(dids), this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap, ctx), ]) - const starterPackUriSet = new Set() + const starterPackUriSet = new Set() state.actors?.forEach((actor) => { if (actor?.profile?.joinedViaStarterPack) { starterPackUriSet.add(actor?.profile?.joinedViaStarterPack?.uri) @@ -338,7 +354,10 @@ export class Hydrator { // app.bsky.graph.defs#listView // - list // - profile basic - async hydrateLists(uris: string[], ctx: HydrateCtx): Promise { + async hydrateLists( + uris: AtUriString[], + ctx: HydrateCtx, + ): Promise { const [listsState, profilesState] = await Promise.all([ this.hydrateListsBasic(uris, ctx, { skipAuthors: true, // handled via author profile hydration @@ -351,7 +370,7 @@ export class Hydrator { // app.bsky.graph.defs#listViewBasic // - list basic async hydrateListsBasic( - uris: string[], + uris: AtUriString[], ctx: HydrateCtx, opts?: { skipAuthors: boolean }, ): Promise { @@ -383,11 +402,11 @@ export class Hydrator { // - profile // - list basic async hydrateListItems( - uris: string[], + uris: AtUriString[], ctx: HydrateCtx, ): Promise { const listItems = await this.graph.getListItems(uris) - const dids: string[] = [] + const dids: DidString[] = [] listItems.forEach((item) => { if (item) { dids.push(item.record.subject) @@ -398,8 +417,8 @@ export class Hydrator { } async hydrateListsMembership( - uris: string[], - did: string, + uris: AtUriString[], + did: DidString, ctx: HydrateCtx, ): Promise { const [ @@ -418,10 +437,10 @@ export class Hydrator { // mapping uri -> did -> { actorListItemUri } const listMemberships = new HydrationMap( uris.map((uri, i) => { - const listItemUri = listItemUris[i] + const listItemUri = listItemUris[i] as '' | AtUriString return [ uri, - new HydrationMap([ + new HydrationMap([ listItemUri ? [did, { actorListItemUri: listItemUri }] : [did, null], @@ -450,17 +469,17 @@ export class Hydrator { // - profile // - list basic async hydratePosts( - refs: ItemRef[], + refs: { uri: AtUriString }[], ctx: HydrateCtx, state: HydrationState = {}, options: Pick = {}, ): Promise { const uris = refs.map((ref) => ref.uri) - state.posts ??= new HydrationMap() + state.posts ??= new HydrationMap() const addPostsToHydrationState = (posts: Posts) => { posts.forEach((post, uri) => { - state.posts ??= new HydrationMap() + state.posts ??= new HydrationMap() state.posts.set(uri, post) }) } @@ -478,13 +497,13 @@ export class Hydrator { addPostsToHydrationState(postsLayer0) const additionalRootUris = rootUrisFromPosts(postsLayer0) // supports computing threadgates - const threadRootUris = new Set() + const threadRootUris = new Set() for (const [uri, post] of postsLayer0) { if (post) { threadRootUris.add(rootUriFromPost(post) ?? uri) } } - const postUrisWithThreadgates = new Set() + const postUrisWithThreadgates = new Set() for (const uri of threadRootUris) { const post = postsLayer0.get(uri) /* @@ -504,7 +523,7 @@ export class Hydrator { const urisLayer1 = nestedRecordUrisFromPosts(postsLayer0) const urisLayer1ByCollection = urisByCollection(urisLayer1) const embedPostUrisLayer1 = - urisLayer1ByCollection.get(ids.AppBskyFeedPost) ?? [] + urisLayer1ByCollection.get(app.bsky.feed.post.$type) ?? [] const postsLayer1 = await this.feed.getPosts( [...embedPostUrisLayer1, ...additionalRootUris], ctx.includeTakedowns, @@ -519,7 +538,7 @@ export class Hydrator { ) const urisLayer2ByCollection = urisByCollection(urisLayer2) const embedPostUrisLayer2 = - urisLayer2ByCollection.get(ids.AppBskyFeedPost) ?? [] + urisLayer2ByCollection.get(app.bsky.feed.post.$type) ?? [] const [postsLayer2, threadgates] = await Promise.all([ this.feed.getPosts( @@ -534,20 +553,20 @@ export class Hydrator { // collect list/feedgen embeds, lists in threadgates, post record hydration const threadgateListUris = getListUrisFromThreadgates(threadgates) const nestedListUris = [ - ...(urisLayer1ByCollection.get(ids.AppBskyGraphList) ?? []), - ...(urisLayer2ByCollection.get(ids.AppBskyGraphList) ?? []), + ...(urisLayer1ByCollection.get(app.bsky.graph.list.$type) ?? []), + ...(urisLayer2ByCollection.get(app.bsky.graph.list.$type) ?? []), ] const nestedFeedGenUris = [ - ...(urisLayer1ByCollection.get(ids.AppBskyFeedGenerator) ?? []), - ...(urisLayer2ByCollection.get(ids.AppBskyFeedGenerator) ?? []), + ...(urisLayer1ByCollection.get(app.bsky.feed.generator.$type) ?? []), + ...(urisLayer2ByCollection.get(app.bsky.feed.generator.$type) ?? []), ] const nestedLabelerDids = [ - ...(urisLayer1ByCollection.get(ids.AppBskyLabelerService) ?? []), - ...(urisLayer2ByCollection.get(ids.AppBskyLabelerService) ?? []), + ...(urisLayer1ByCollection.get(app.bsky.labeler.service.$type) ?? []), + ...(urisLayer2ByCollection.get(app.bsky.labeler.service.$type) ?? []), ].map(didFromUri) const nestedStarterPackUris = [ - ...(urisLayer1ByCollection.get(ids.AppBskyGraphStarterpack) ?? []), - ...(urisLayer2ByCollection.get(ids.AppBskyGraphStarterpack) ?? []), + ...(urisLayer1ByCollection.get(app.bsky.graph.starterpack.$type) ?? []), + ...(urisLayer2ByCollection.get(app.bsky.graph.starterpack.$type) ?? []), ] const posts = mergeManyMaps(postsLayer0, postsLayer1, postsLayer2) ?? postsLayer0 @@ -561,7 +580,7 @@ export class Hydrator { ...ref, threadRoot: posts.get(ref.uri)?.record.reply?.root.uri ?? ref.uri, })) - const postUrisWithPostgates = new Set() + const postUrisWithPostgates = new Set() for (const [uri, post] of posts) { if (post && post.hasPostGate) { postUrisWithPostgates.add(uri) @@ -620,8 +639,8 @@ export class Hydrator { posts: Posts, ctx: HydrateCtx, ): Promise { - const postBlocks = new HydrationMap() - const postBlocksPairs = new Map() + const postBlocks: PostBlocks = new HydrationMap() + const postBlocksPairs = new Map() const relationships: RelationshipPair[] = [] for (const [uri, item] of posts) { if (!item) continue @@ -690,8 +709,8 @@ export class Hydrator { items.map((item) => item.post.uri), ctx.includeTakedowns, ) - const rootUris: string[] = [] - const parentUris: string[] = [] + const rootUris: AtUriString[] = [] + const parentUris: AtUriString[] = [] const postAndReplyRefs: ItemRef[] = [] posts.forEach((post, uri) => { if (!post) return @@ -707,7 +726,7 @@ export class Hydrator { [...rootUris, ...parentUris], ctx.includeTakedowns, ) - const replyParentAuthors: string[] = [] + const replyParentAuthors: DidString[] = [] parentUris.forEach((uri) => { const parent = replies.get(uri) if (!parent?.record.reply) return @@ -746,31 +765,27 @@ export class Hydrator { ctx: HydrateCtx, ): Promise { const postsState = await this.hydratePosts(refs, ctx, undefined, { - processDynamicTagsForView: ctx.featureGates.get( - FeatureGateID.ThreadsReplyRankingExplorationEnable, + processDynamicTagsForView: ctx.features.checkGate( + ctx.features.Gate.ThreadsReplyRankingExplorationEnable, ) ? 'thread' : undefined, }) - const { posts } = postsState - const postsList = posts ? Array.from(posts.entries()) : [] + const threadRefs: ThreadRef[] = [] - const isDefined = ( - entry: [string, Post | null], - ): entry is [string, Post] => { - const [, post] = entry - return !!post + if (postsState.posts) { + for (const [uri, post] of postsState.posts.entries()) { + if (post) { + threadRefs.push({ + uri, + cid: post.cid, + threadRoot: post.record.reply?.root.uri ?? uri, + }) + } + } } - const threadRefs: ThreadRef[] = postsList - .filter(isDefined) - .map(([uri, post]) => ({ - uri, - cid: post.cid, - threadRoot: post.record.reply?.root.uri ?? uri, - })) - const threadContexts = await this.feed.getThreadContexts(threadRefs) return mergeStates(postsState, { threadContexts }) @@ -781,7 +796,7 @@ export class Hydrator { // - profile // - list basic async hydrateFeedGens( - uris: string[], // @TODO any way to get refs here? + uris: AtUriString[], // @TODO any way to get refs here? ctx: HydrateCtx, ): Promise { const [feedgens, feedgenAggs, feedgenViewers, profileState, labels] = @@ -815,7 +830,7 @@ export class Hydrator { // - list basic // - labels async hydrateStarterPacksBasic( - uris: string[], + uris: AtUriString[], ctx: HydrateCtx, ): Promise { const [starterPacks, starterPackAggs, profileState, labels] = @@ -849,13 +864,13 @@ export class Hydrator { // - list basic // - labels async hydrateStarterPacks( - uris: string[], + uris: AtUriString[], ctx: HydrateCtx, ): Promise { const starterPackState = await this.hydrateStarterPacksBasic(uris, ctx) // gather feed and list uris - const feedUriSet = new Set() - const listUriSet = new Set() + const feedUriSet = new Set() + const listUriSet = new Set() starterPackState.starterPacks?.forEach((sp) => { sp?.record.feeds?.forEach((feed) => feedUriSet.add(feed.uri)) if (sp?.record.list) { @@ -877,13 +892,13 @@ export class Hydrator { listUris.map((uri, i) => [uri, listsMembers[i]]), ) const listMemberDids = listsMembers.flatMap((lm) => - lm.listitems.map((li) => li.did), + lm.listitems.map((li) => li.did as DidString), ) const listCreatorMemberPairs = [...listMembersByList.entries()].flatMap( ([listUri, members]) => { const creator = didFromUri(listUri) return members.listitems.map( - (li): RelationshipPair => [creator, li.did], + (li): RelationshipPair => [creator, li.did as DidString], ) }, ) @@ -893,7 +908,7 @@ export class Hydrator { ) // sample top list items per starter pack based on their follows const listMemberAggs = await this.actor.getProfileAggregates(listMemberDids) - const listItemUris: string[] = [] + const listItemUris: AtUriString[] = [] uris.forEach((uri) => { const sp = starterPackState.starterPacks?.get(uri) const agg = starterPackState.starterPackAggs?.get(uri) @@ -905,16 +920,19 @@ export class Hydrator { agg.listItemSampleUris = [ ...members.listitems.filter( (li) => - ctx.viewer === creator || !isBlocked(blocks, [creator, li.did]), + ctx.viewer === creator || + !isBlocked(blocks, [creator, li.did as DidString]), ), ] .sort((li1, li2) => { - const score1 = listMemberAggs.get(li1.did)?.followers ?? 0 - const score2 = listMemberAggs.get(li2.did)?.followers ?? 0 + const score1 = + listMemberAggs.get(li1.did as DidString)?.followers ?? 0 + const score2 = + listMemberAggs.get(li2.did as DidString)?.followers ?? 0 return score2 - score1 }) .slice(0, 12) - .map((li) => li.uri) + .map((li) => li.uri as AtUriString) listItemUris.push(...agg.listItemSampleUris) }) // hydrate sampled list items @@ -932,8 +950,8 @@ export class Hydrator { // - profile // - list basic async hydrateLikes( - authorDid: string, - uris: string[], + authorDid: DidString, + uris: AtUriString[], ctx: HydrateCtx, ): Promise { const [likes, profileState] = await Promise.all([ @@ -948,7 +966,7 @@ export class Hydrator { } } const blocks = await this.hydrateBidirectionalBlocks(pairsToMap(pairs), ctx) - const likeBlocks = new HydrationMap() + const likeBlocks = new HydrationMap() for (const [uri, like] of likes) { if (like) { likeBlocks.set(uri, isBlocked(blocks, [authorDid, didFromUri(uri)])) @@ -964,7 +982,7 @@ export class Hydrator { // - repost // - profile // - list basic - async hydrateReposts(uris: string[], ctx: HydrateCtx) { + async hydrateReposts(uris: AtUriString[], ctx: HydrateCtx) { const [reposts, profileState] = await Promise.all([ this.feed.getReposts(uris, ctx.includeTakedowns), this.hydrateProfiles(uris.map(didFromUri), ctx), @@ -980,13 +998,14 @@ export class Hydrator { notifs: Notification[], ctx: HydrateCtx, ): Promise { - const uris = notifs.map((notif) => notif.uri) + const uris = notifs.map((notif) => notif.uri as AtUriString) const collections = urisByCollection(uris) - const postUris = collections.get(ids.AppBskyFeedPost) ?? [] - const likeUris = collections.get(ids.AppBskyFeedLike) ?? [] - const repostUris = collections.get(ids.AppBskyFeedRepost) ?? [] - const followUris = collections.get(ids.AppBskyGraphFollow) ?? [] - const verificationUris = collections.get(ids.AppBskyGraphVerification) ?? [] + const postUris = collections.get(app.bsky.feed.post.$type) ?? [] + const likeUris = collections.get(app.bsky.feed.like.$type) ?? [] + const repostUris = collections.get(app.bsky.feed.repost.$type) ?? [] + const followUris = collections.get(app.bsky.graph.follow.$type) ?? [] + const verificationUris = + collections.get(app.bsky.graph.verification.$type) ?? [] const [ posts, likes, @@ -1004,10 +1023,10 @@ export class Hydrator { this.label.getLabelsForSubjects(uris, ctx.labelers), this.hydrateProfiles(uris.map(didFromUri), ctx), ]) - const viewerRootPostUris = new Set() + const viewerRootPostUris = new Set() for (const notif of notifs) { if (notif.reason === 'reply') { - const post = posts.get(notif.uri) + const post = posts.get(notif.uri as AtUriString) if (post) { const rootUri = post.record.reply?.root.uri if (rootUri && didFromUri(rootUri) === ctx.viewer) { @@ -1043,29 +1062,38 @@ export class Hydrator { uris: bookmarkInfos.map((b) => b.subject), }) - type BookmarkWithRef = Bookmark & { ref: RecordRef } + type BookmarkWithRef = BookmarkLex & { ref: RecordRef } const bookmarks: BookmarkWithRef[] = bookmarksRes.bookmarks.filter( (bookmark): bookmark is BookmarkWithRef => !!bookmark.ref?.key, ) + // mapping DID -> stash key -> bookmark - const bookmarksMap = new HydrationMap([ - [ - viewer, - new HydrationMap( - bookmarks.map((bookmark) => { - const { - ref: { key }, - } = bookmark - return [key, bookmark] - }), - ), - ], - ]) + const bookmarksMap = new HydrationMap< + DidString, + HydrationMap + >() + + bookmarksMap.set( + viewer, + new HydrationMap( + bookmarks.map((bookmark) => [ + bookmark.ref.key, + { + ref: bookmark.ref, + subjectUri: bookmark.subjectUri, + subjectCid: bookmark.subjectCid, + indexedAt: parseDate(bookmark.indexedAt), + }, + ]), + ), + ) // @NOTE: The `createBookmark` endpoint limits bookmarks to be of posts, // so we can assume currently all subjects are posts. const postsState = await this.hydratePosts( - bookmarks.map((bookmark) => ({ uri: bookmark.subjectUri })), + bookmarks.map((bookmark) => ({ + uri: bookmark.subjectUri as AtUriString, + })), ctx, ) @@ -1074,7 +1102,7 @@ export class Hydrator { // provides partial hydration state within getFollows / getFollowers, mainly for applying rules async hydrateFollows( - uris: string[], + uris: AtUriString[], ctx: HydrateCtx, ): Promise { const follows = await this.graph.getFollows(uris) @@ -1085,7 +1113,7 @@ export class Hydrator { } } const blocks = await this.hydrateBidirectionalBlocks(pairsToMap(pairs), ctx) - const followBlocks = new HydrationMap() + const followBlocks: FollowBlocks = new HydrationMap() for (const [uri, follow] of follows) { if (follow) { followBlocks.set( @@ -1100,7 +1128,7 @@ export class Hydrator { } async hydrateBidirectionalBlocks( - didMap: Map, // DID -> DID[] + didMap: Map, // DID -> DID[] ctx: HydrateCtx, ): Promise { const pairs: RelationshipPair[] = [] @@ -1111,7 +1139,7 @@ export class Hydrator { } const blocks = await this.graph.getBidirectionalBlocks(pairs) - const listUrisSet = new Set() + const listUrisSet = new Set() for (const [source, targets] of didMap) { for (const target of targets) { const block = blocks.get(source, target) @@ -1133,11 +1161,10 @@ export class Hydrator { } } - const result: BidirectionalBlocks = new HydrationMap< - HydrationMap - >() + const result: BidirectionalBlocks = new HydrationMap() + for (const [source, targets] of didMap) { - const didBlocks = new HydrationMap() + const didBlocks = new HydrationMap() for (const target of targets) { const block = blocks.get(source, target) @@ -1172,7 +1199,7 @@ export class Hydrator { // - profile // - list basic async hydrateLabelers( - dids: string[], + dids: DidString[], ctx: HydrateCtx, ): Promise { const [labelers, labelerAggs, labelerViewers, profileState] = @@ -1195,102 +1222,102 @@ export class Hydrator { // ad-hoc record hydration // in com.atproto.repo.getRecord - async getRecord(uri: string, includeTakedowns = false) { + async getRecord(uri: AtUriString, includeTakedowns = false) { const parsed = new AtUri(uri) const collection = parsed.collection - if (collection === ids.AppBskyFeedPost) { + if (collection === app.bsky.feed.post.$type) { return ( (await this.feed.getPosts([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyFeedRepost) { + } else if (collection === app.bsky.feed.repost.$type) { return ( (await this.feed.getReposts([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyFeedLike) { + } else if (collection === app.bsky.feed.like.$type) { return ( (await this.feed.getLikes([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyGraphFollow) { + } else if (collection === app.bsky.graph.follow.$type) { return ( (await this.graph.getFollows([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyGraphList) { + } else if (collection === app.bsky.graph.list.$type) { return ( (await this.graph.getLists([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyGraphListitem) { + } else if (collection === app.bsky.graph.listitem.$type) { return ( (await this.graph.getListItems([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyGraphBlock) { + } else if (collection === app.bsky.graph.block.$type) { return ( (await this.graph.getBlocks([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyGraphStarterpack) { + } else if (collection === app.bsky.graph.starterpack.$type) { return ( (await this.graph.getStarterPacks([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyFeedGenerator) { + } else if (collection === app.bsky.feed.generator.$type) { return ( (await this.feed.getFeedGens([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyFeedThreadgate) { + } else if (collection === app.bsky.feed.threadgate.$type) { return ( (await this.feed.getThreadgateRecords([uri], includeTakedowns)).get( uri, ) ?? undefined ) - } else if (collection === ids.AppBskyFeedPostgate) { + } else if (collection === app.bsky.feed.postgate.$type) { return ( (await this.feed.getPostgateRecords([uri], includeTakedowns)).get( uri, ) ?? undefined ) - } else if (collection === ids.AppBskyLabelerService) { + } else if (collection === app.bsky.labeler.service.$type) { if (parsed.rkey !== 'self') return - const did = parsed.hostname + const { did } = parsed return ( (await this.label.getLabelers([did], includeTakedowns)).get(did) ?? undefined ) - } else if (collection === ids.ChatBskyActorDeclaration) { + } else if (collection === chat.bsky.actor.declaration.$type) { if (parsed.rkey !== 'self') return return ( (await this.actor.getChatDeclarations([uri], includeTakedowns)).get( uri, ) ?? undefined ) - } else if (collection === ids.ComGermnetworkDeclaration) { + } else if (collection === com.germnetwork.declaration.$type) { if (parsed.rkey !== 'self') return return ( (await this.actor.getGermDeclarations([uri], includeTakedowns)).get( uri, ) ?? undefined ) - } else if (collection === ids.AppBskyNotificationDeclaration) { + } else if (collection === app.bsky.notification.declaration.$type) { if (parsed.rkey !== 'self') return return ( ( await this.actor.getNotificationDeclarations([uri], includeTakedowns) ).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyActorStatus) { + } else if (collection === app.bsky.actor.status.$type) { if (parsed.rkey !== 'self') return return ( (await this.actor.getStatus([uri], includeTakedowns)).get(uri) ?? undefined ) - } else if (collection === ids.AppBskyActorProfile) { - const did = parsed.hostname + } else if (collection === app.bsky.actor.profile.$type) { + const did = parsed.did const actor = ( await this.actor.getActors([did], { includeTakedowns }) ).get(did) @@ -1307,7 +1334,11 @@ export class Hydrator { } } - async createContext(vals: HydrateCtxVals) { + async createContext< + V extends Omit & { + features?: ScopedFeatureGatesClient + }, + >(vals: V): Promise { // ensures we're only apply labelers that exist and are not taken down const labelers = vals.labelers.dids const nonServiceLabelers = labelers.filter( @@ -1330,12 +1361,14 @@ export class Hydrator { viewer: vals.viewer, includeTakedowns: vals.includeTakedowns, include3pBlocks: vals.include3pBlocks, + skipViewerBlocks: vals.skipViewerBlocks, includeDebugField, - featureGates: vals.featureGates, + // create default anonymous scope + features: vals.features || this.config.featureGatesClient.scope({}), }) } - async resolveUri(uriStr: string) { + async resolveUri(uriStr: AtUriString): Promise { const uri = new AtUri(uriStr) const [did] = await this.actor.getDids([uri.host]) if (!did) return uriStr @@ -1347,11 +1380,13 @@ export class Hydrator { // service refs may look like "did:plc:example#service_id". we want to extract the did part "did:plc:example". const serviceRefToDid = (serviceRef: string) => { const idx = serviceRef.indexOf('#') - return idx !== -1 ? serviceRef.slice(0, idx) : serviceRef + return (idx !== -1 ? serviceRef.slice(0, idx) : serviceRef) as DidString } -const listUrisFromProfileViewer = (item: ProfileViewerState | null) => { - const listUris: string[] = [] +const listUrisFromProfileViewer = ( + item: ProfileViewerState | null, +): AtUriString[] => { + const listUris: AtUriString[] = [] if (item?.mutedByList) { listUris.push(item.mutedByList) } @@ -1381,7 +1416,7 @@ const removeNonModListsFromProfileViewer = ( } const isModList = ( - listUri: string | undefined, + listUri: AtUriString | undefined, state: HydrationState, ): boolean => { if (!listUri) return false @@ -1389,17 +1424,20 @@ const isModList = ( return list?.record.purpose === 'app.bsky.graph.defs#modlist' } -const labelSubjectsForDid = (dids: string[]) => { +const labelSubjectsForDid = (dids: DidString[]) => { return [ ...dids, ...dids.map((did) => - AtUri.make(did, ids.AppBskyActorProfile, 'self').toString(), + AtUri.make(did, app.bsky.actor.profile.$type, 'self').toString(), + ), + ...dids.map((did) => + AtUri.make(did, app.bsky.actor.status.$type, 'self').toString(), ), ] } -const rootUrisFromPosts = (posts: Posts): string[] => { - const uris: string[] = [] +const rootUrisFromPosts = (posts: Posts): AtUriString[] => { + const uris: AtUriString[] = [] for (const item of posts.values()) { const rootUri = item && rootUriFromPost(item) if (rootUri) { @@ -1409,15 +1447,15 @@ const rootUrisFromPosts = (posts: Posts): string[] => { return uris } -const rootUriFromPost = (post: Post): string | undefined => { +const rootUriFromPost = (post: Post): AtUriString | undefined => { return post.record.reply?.root.uri } const nestedRecordUrisFromPosts = ( posts: Posts, - fromUris?: string[], -): string[] => { - const uris: string[] = [] + fromUris?: AtUriString[], +): AtUriString[] => { + const uris: AtUriString[] = [] const postUris = fromUris ?? posts.keys() for (const uri of postUris) { const item = posts.get(uri) @@ -1428,21 +1466,21 @@ const nestedRecordUrisFromPosts = ( return uris } -const nestedRecordUris = (post: Post['record']): string[] => { - const uris: string[] = [] +const nestedRecordUris = (post: Post['record']): AtUriString[] => { + const uris: AtUriString[] = [] if (!post?.embed) return uris - if (isEmbedRecord(post.embed)) { + if (isRecordEmbedType(post.embed)) { uris.push(post.embed.record.uri) - } else if (isEmbedRecordWithMedia(post.embed)) { + } else if (isRecordWithMediaType(post.embed)) { uris.push(post.embed.record.record.uri) } return uris } -const getListUrisFromThreadgates = (gates: Threadgates) => { - const uris: string[] = [] +const getListUrisFromThreadgates = (gates: Threadgates): AtUriString[] => { + const uris: AtUriString[] = [] for (const gate of gates.values()) { - const listRules = gate?.record.allow?.filter(isThreadgateListRule) ?? [] + const listRules = gate?.record.allow?.filter(isListRuleType) ?? [] for (const rule of listRules) { uris.push(rule.list) } @@ -1454,12 +1492,12 @@ const isBlocked = (blocks: BidirectionalBlocks, [a, b]: RelationshipPair) => { return blocks.get(a)?.get(b) ?? false } -const pairsToMap = (pairs: RelationshipPair[]): Map => { - const map = new Map() +const pairsToMap = (pairs: [a: K, b: K][]): Map => { + const map = new Map() for (const [a, b] of pairs) { - const list = map.get(a) ?? [] - list.push(b) - map.set(a, list) + const list = map.get(a) + if (list) list.push(b) + else map.set(a, [b]) } return map } @@ -1526,9 +1564,9 @@ export const mergeManyStates = (...states: HydrationState[]) => { return states.reduce(mergeStates, {} as HydrationState) } -const actionTakedownLabels = ( - keys: string[], - hydrationMap: HydrationMap, +const actionTakedownLabels = ( + keys: UriString[], + hydrationMap: HydrationMap, labels: Labels, ) => { for (const key of keys) { @@ -1538,6 +1576,6 @@ const actionTakedownLabels = ( } } -const uriToRef = (uri: string): ItemRef => { +const uriToRef = (uri: AtUriString): ItemRef => { return { uri } } diff --git a/packages/bsky/src/hydration/label.ts b/packages/bsky/src/hydration/label.ts index efedb287c64..a17c91038fe 100644 --- a/packages/bsky/src/hydration/label.ts +++ b/packages/bsky/src/hydration/label.ts @@ -1,9 +1,8 @@ -import { AtUri } from '@atproto/syntax' +import { AtUri, AtUriString, DidString, UriString } from '@atproto/syntax' import { DataPlaneClient } from '../data-plane/client' -import { ids } from '../lexicon/lexicons' -import { Record as LabelerRecord } from '../lexicon/types/app/bsky/labeler/service' -import { Label } from '../lexicon/types/com/atproto/label/defs' +import { app, com } from '../lexicons/index.js' import { ParsedLabelers } from '../util' +import { Label, LabelerRecord } from '../views/types.js' import { HydrationMap, Merges, @@ -13,22 +12,25 @@ import { parseString, } from './util' -export type { Label } from '../lexicon/types/com/atproto/label/defs' +export type { Label } export type SubjectLabels = { isImpersonation: boolean isTakendown: boolean needsReview: boolean - labels: HydrationMap