[SDK] Validate and brand the persisted account did (#11387)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-13 22:26:22 +03:00
committed by GitHub
parent 9fe0084874
commit 3677c98873
46 changed files with 154 additions and 205 deletions
@@ -1,6 +1,5 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type DidString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -62,8 +61,7 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
reasonType: tools.ozone.report.defs.reasonAppeal.value, reasonType: tools.ozone.report.defs.reasonAppeal.value,
subject: { subject: {
$type: 'com.atproto.admin.defs#repoRef', $type: 'com.atproto.admin.defs#repoRef',
// the persisted account did is already resolved did: currentAccount.did,
did: currentAccount.did as DidString,
}, },
reason: `AGE_ASSURANCE_INQUIRY: ` + details, reason: `AGE_ASSURANCE_INQUIRY: ` + details,
}, },
@@ -1,12 +1,7 @@
import {View} from 'react-native' import {View} from 'react-native'
import {TID} from '@atproto/common-web' import {TID} from '@atproto/common-web'
import {type $Typed} from '@atproto/lex' import {type $Typed} from '@atproto/lex'
import { import {AtUri, type AtUriString, toDatetimeString} from '@atproto/syntax'
type AtIdentifierString,
AtUri,
type AtUriString,
toDatetimeString,
} from '@atproto/syntax'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -99,7 +94,7 @@ export function CreateListFromStarterPackDialog({
const chunks = chunk(listitemWrites, 50) const chunks = chunk(listitemWrites, 50)
for (const c of chunks) { for (const c of chunks) {
await pdsClient.call(com.atproto.repo.applyWrites, { await pdsClient.call(com.atproto.repo.applyWrites, {
repo: currentAccount.did as AtIdentifierString, repo: currentAccount.did,
writes: c, writes: c,
}) })
} }
@@ -67,7 +67,7 @@ export function useSubmitReportMutation() {
reason: state.details, reason: state.details,
subject: { subject: {
$type: 'com.atproto.admin.defs#repoRef', $type: 'com.atproto.admin.defs#repoRef',
// the parsed subject carries an already-resolved did // the parsed subject holds the did as a plain string
did: subject.did as DidString, did: subject.did as DidString,
}, },
} }
+2 -4
View File
@@ -76,15 +76,13 @@ export const LOG_DEBUG: string = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
* The DID of the Bluesky appview to proxy to * The DID of the Bluesky appview to proxy to
*/ */
export const BLUESKY_PROXY_DID: DidString = export const BLUESKY_PROXY_DID: DidString =
(process.env.EXPO_PUBLIC_BLUESKY_PROXY_DID as DidString) || process.env.EXPO_PUBLIC_BLUESKY_PROXY_DID || 'did:web:api.bsky.app'
'did:web:api.bsky.app'
/** /**
* The DID of the chat service to proxy to * The DID of the chat service to proxy to
*/ */
export const CHAT_PROXY_DID: DidString = export const CHAT_PROXY_DID: DidString =
(process.env.EXPO_PUBLIC_CHAT_PROXY_DID as DidString) || process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat'
'did:web:api.bsky.chat'
/** /**
* Metrics API host * Metrics API host
+3 -4
View File
@@ -1,7 +1,7 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {retry} from '@atproto/common-web' import {retry} from '@atproto/common-web'
import {type $Typed, type l, type UriString} from '@atproto/lex' import {type $Typed, type l, type UriString} from '@atproto/lex'
import {type AtIdentifierString, AtUri, toDatetimeString} from '@atproto/syntax' import {AtUri, toDatetimeString} from '@atproto/syntax'
import {moderateStatus} from '@bsky.app/sdk/moderation' import {moderateStatus} from '@bsky.app/sdk/moderation'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -276,8 +276,7 @@ export function useUpsertLiveStatusMutation(
} satisfies app.bsky.actor.status.Main } satisfies app.bsky.actor.status.Main
const upsert = async () => { const upsert = async () => {
// the session account is still legacy-typed, so its did is unbranded const repo = currentAccount.did
const repo = currentAccount.did as AtIdentifierString
const collection = 'app.bsky.actor.status' const collection = 'app.bsky.actor.status'
const existing = await pdsClient const existing = await pdsClient
@@ -369,7 +368,7 @@ export function useRemoveLiveStatusMutation() {
if (!currentAccount) throw new Error('Not logged in') if (!currentAccount) throw new Error('Not logged in')
await pdsClient.delete(app.bsky.actor.status, { await pdsClient.delete(app.bsky.actor.status, {
repo: currentAccount.did as AtIdentifierString, repo: currentAccount.did,
rkey: 'self', rkey: 'self',
}) })
}, },
+76 -51
View File
@@ -1,5 +1,11 @@
import {Client, lexParse, type XrpcRequestParams} from '@atproto/lex' import {
type Client,
type XrpcRequestParams,
XrpcResponseError,
} from '@atproto/lex'
import {PUBLIC_APPVIEW} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import { import {
getAppLanguageAsContentLanguage, getAppLanguageAsContentLanguage,
getContentLanguages, getContentLanguages,
@@ -96,68 +102,87 @@ export class CustomFeedAPI implements FeedAPI {
} }
} }
// HACK let loggedOutAppviewClient: Client | undefined
// we want feeds to give language-specific results immediately when a
// logged-out user changes their language. this comes with two problems:
// 1. not all languages have content, and
// 2. our public caching layer isnt correctly busting against the accept-language header
// for now we handle both of these with a manual workaround
// -prf
async function loggedOutFetch({
feed,
limit,
cursor,
}: {
feed: string
limit: number
cursor?: string
}): Promise<app.bsky.feed.getFeed.$OutputBody | null> {
let contentLangs = getAppLanguageAsContentLanguage()
/* /**
* This request is hand-rolled rather than issued through a client, so it has * The unauthenticated {@link Client} for logged-out feed reads, pointed at the
* to reproduce the header lex would have emitted from the global static. * direct appview ({@link PUBLIC_APPVIEW}, `api.bsky.app`).
*
* Deliberately NOT the public appview client (`public.api.bsky.app`): that host
* fronts a cache which does not vary on `Accept-Language`, so it would answer a
* language-filtered read from another language's cached body. The direct
* appview respects the header (verified 2026-08-04), at the cost of not being
* cached. See {@link loggedOutFetch}.
*
* A single module-level instance, because there is no session to scope it to.
* Like the public chat client, it uses plain `fetch` rather than
* `networkAwareFetch`, matching the ad-hoc fetch it replaces: this read has its
* own failure handling and should not move the app-wide network signal.
*/ */
const labelersHeader = { function getLoggedOutAppviewClient(): Client {
'atproto-accept-labelers': Client.appLabelers return (loggedOutAppviewClient ??= createLexClient({
.map(l => `${l};redact`) service: PUBLIC_APPVIEW,
.join(', '), }))
} }
// manually construct fetch call so we can add the `lang` cache-busting param /*
let res = await fetch( * HACK
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ * We want feeds to give language-specific results immediately when a logged-out
cursor ? `&cursor=${cursor}` : '' * user changes their language. That comes with two problems:
}&limit=${limit}&lang=${contentLangs}`, * 1. not all languages have content, and
{ * 2. our public caching layer does not bust against the `Accept-Language`
method: 'GET', * header.
headers: {'Accept-Language': contentLangs, ...labelersHeader}, * -prf
}, *
) * Problem 2 is why this uses its own client rather than the app's public
/* * appview one: it talks to the direct appview, which honors the header, instead
* The response is hand-decoded rather than validated, so the lex output shape * of the cached `public.api.bsky.app`, which does not vary on it. That trades
* is asserted here just as the old-world one was. * CDN caching for language correctness on logged-out feed traffic.
*
* Problem 1 is host-independent, so it is still handled here: an empty
* language-filtered feed is retried once with the language constraint removed.
*/ */
let data = res.ok async function loggedOutFetch(
? (lexParse(await res.text()) as app.bsky.feed.getFeed.$OutputBody) params: GetCustomFeedParams,
: null ): Promise<app.bsky.feed.getFeed.$OutputBody | null> {
const contentLangs = getAppLanguageAsContentLanguage()
let data = await getFeedOrNull(params, contentLangs)
if (data?.feed?.length) { if (data?.feed?.length) {
return data return data
} }
// no data, try again with language headers removed // no data, try again with language headers removed
res = await fetch( data = await getFeedOrNull(params, '')
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}`,
{method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}},
)
data = res.ok
? (lexParse(await res.text()) as app.bsky.feed.getFeed.$OutputBody)
: null
if (data?.feed?.length) { if (data?.feed?.length) {
return data return data
} }
return null return null
} }
/**
* A logged-out `getFeed` read that resolves to null on a response error.
*
* The pre-client code only guarded `res.ok`, so a failed RESPONSE fell through
* to the next attempt while a failed REQUEST rejected. Catching
* `XrpcResponseError` preserves that split: every other lex error - the fetch
* and validation ones - still propagates.
*/
async function getFeedOrNull(
params: GetCustomFeedParams,
contentLangs: string,
): Promise<app.bsky.feed.getFeed.$OutputBody | null> {
try {
return await getLoggedOutAppviewClient().call(
app.bsky.feed.getFeed,
params,
{headers: {'Accept-Language': contentLangs}},
)
} catch (e) {
if (e instanceof XrpcResponseError) {
return null
}
throw e
}
}
-10
View File
@@ -307,11 +307,6 @@ async function resolveEmbed(
if (resolvedLink.type === 'record') { if (resolvedLink.type === 'record') {
return { return {
$type: 'app.bsky.embed.record', $type: 'app.bsky.embed.record',
/*
* `resolve.ts` is still legacy-typed - its strong refs and URIs carry
* unbranded strings. Assert at the boundary until it moves to the
* clients.
*/
record: resolvedLink.record, record: resolvedLink.record,
} }
} }
@@ -495,11 +490,6 @@ async function resolveMedia(
return undefined return undefined
} }
/*
* `resolve.ts` still returns legacy-typed views, so its strong refs carry plain
* strings where the record write wants the branded syntax types; assert at the
* boundary until those views come from the generated lexicons.
*/
async function resolveRecord( async function resolveRecord(
clients: LinkResolvers, clients: LinkResolvers,
queryClient: QueryClient, queryClient: QueryClient,
+3 -3
View File
@@ -1,4 +1,4 @@
import {AtUri} from '@atproto/syntax' import {type AtIdentifierString, AtUri} from '@atproto/syntax'
import {parse} from 'psl' import {parse} from 'psl'
import TLDs from 'tlds' import TLDs from 'tlds'
@@ -43,8 +43,8 @@ export function makeRecordUri(
rkey: string, rkey: string,
) { ) {
const urip = new AtUri('at://placeholder.placeholder/') const urip = new AtUri('at://placeholder.placeholder/')
// @ts-expect-error TODO new-sdk-migration // the helper takes the did or handle as a plain string
urip.host = didOrName urip.host = didOrName as AtIdentifierString
urip.collection = collection urip.collection = collection
urip.rkey = rkey urip.rkey = rkey
return urip.toString() return urip.toString()
@@ -1,6 +1,5 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type DidString} from '@atproto/syntax'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
@@ -106,8 +105,7 @@ function DialogInner() {
reasonType: tools.ozone.report.defs.reasonAppeal.value, reasonType: tools.ozone.report.defs.reasonAppeal.value,
subject: { subject: {
$type: 'com.atproto.admin.defs#repoRef', $type: 'com.atproto.admin.defs#repoRef',
// the persisted account did is already resolved did: currentAccount.did,
did: currentAccount.did as DidString,
}, },
reason: details, reason: details,
}, },
@@ -567,10 +567,6 @@ export function MessagesList({
if (post) { if (post) {
embed = { embed = {
$type: 'app.bsky.embed.record', $type: 'app.bsky.embed.record',
/*
* `getPost` hands back `uri` and `cid` as plain strings rather
* than the branded syntax types the lexicon input declares.
*/
record: { record: {
uri: post.uri, uri: post.uri,
cid: post.cid, cid: post.cid,
@@ -71,10 +71,6 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
$type: 'app.bsky.feed.threadgate', $type: 'app.bsky.feed.threadgate',
post: '' as AtUriString, post: '' as AtUriString,
createdAt: toDatetimeString(new Date()), createdAt: toDatetimeString(new Date()),
/*
* Preferences are still typed against the legacy client, so the stored
* rules arrive unbranded. Wave B migrates `getPreferences`.
*/
allow: preferences.postInteractionSettings.threadgateAllowRules, allow: preferences.postInteractionSettings.threadgateAllowRules,
}) })
}, [preferences.postInteractionSettings.threadgateAllowRules]) }, [preferences.postInteractionSettings.threadgateAllowRules])
@@ -75,7 +75,6 @@ export function StarterPackCard({
let followUris: Map<string, string> let followUris: Map<string, string>
try { try {
followUris = await bulkWriteFollows(pdsClient, appviewClient, dids, { followUris = await bulkWriteFollows(pdsClient, appviewClient, dids, {
// the starter pack view is still legacy-typed
uri: view.uri, uri: view.uri,
cid: view.cid, cid: view.cid,
}) })
+1 -1
View File
@@ -21,7 +21,7 @@ export async function bulkWriteFollows(
const followRecords: $Typed<app.bsky.graph.follow.Main>[] = dids.map(did => { const followRecords: $Typed<app.bsky.graph.follow.Main>[] = dids.map(did => {
return { return {
$type: 'app.bsky.graph.follow', $type: 'app.bsky.graph.follow',
// callers hold plain dids read off legacy-typed views // the helper takes the dids as plain strings
subject: did as DidString, subject: did as DidString,
createdAt: toDatetimeString(new Date()), createdAt: toDatetimeString(new Date()),
via, via,
@@ -124,7 +124,7 @@ function GermSelfButton({did}: {did: string}) {
const {mutate: deleteDeclaration, isPending} = useMutation({ const {mutate: deleteDeclaration, isPending} = useMutation({
mutationFn: async () => { mutationFn: async () => {
const previousRecord = await pdsClient const previousRecord = await pdsClient
// the profile view is still legacy-typed, so its did is unbranded // the component takes the did as a plain string prop
.get(com.germnetwork.declaration, { .get(com.germnetwork.declaration, {
repo: did as DidString, repo: did as DidString,
rkey: 'self', rkey: 'self',
@@ -1,6 +1,5 @@
import {useCallback, useRef, useState} from 'react' import {useCallback, useRef, useState} from 'react'
import {type TextInput, View} from 'react-native' import {type TextInput, View} from 'react-native'
import {type DidString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -126,8 +125,7 @@ function DeleteAccountDialogInner({
*/ */
await chatClient.call(chat.bsky.actor.deleteAccount) await chatClient.call(chat.bsky.actor.deleteAccount)
await client.call(com.atproto.server.deleteAccount, { await client.call(com.atproto.server.deleteAccount, {
// the persisted account did is already resolved did: currentAccount.did,
did: currentAccount.did as DidString,
password, password,
token, token,
}) })
@@ -1,6 +1,5 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type DidString} from '@atproto/syntax'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {saveBytesToDisk} from '#/lib/media/manip' import {saveBytesToDisk} from '#/lib/media/manip'
@@ -34,7 +33,7 @@ export function ExportCarDialog({
} }
try { try {
setLoading('repo') setLoading('repo')
const did = currentAccount.did as DidString const did = currentAccount.did
const data = await pdsClient.call(com.atproto.sync.getRepo, {did}) const data = await pdsClient.call(com.atproto.sync.getRepo, {did})
/* /*
* getRepo declares `application/vnd.ipld.car`, so lex-client hands back * getRepo declares `application/vnd.ipld.car`, so lex-client hands back
@@ -376,7 +376,6 @@ function Header({
let followUris: Map<string, string> let followUris: Map<string, string>
try { try {
followUris = await bulkWriteFollows(pdsClient, appviewClient, dids, { followUris = await bulkWriteFollows(pdsClient, appviewClient, dids, {
// the starter pack view is still legacy-typed
uri: starterPack.uri, uri: starterPack.uri,
cid: starterPack.cid, cid: starterPack.cid,
}) })
+1 -3
View File
@@ -2,7 +2,6 @@ import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {type DidString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
@@ -57,8 +56,7 @@ export function Takendown() {
reasonType: tools.ozone.report.defs.reasonAppeal.value, reasonType: tools.ozone.report.defs.reasonAppeal.value,
subject: { subject: {
$type: 'com.atproto.admin.defs#repoRef', $type: 'com.atproto.admin.defs#repoRef',
// the persisted account did is already resolved did: currentAccount.did,
did: currentAccount.did as DidString,
}, },
reason: appealText, reason: appealText,
}, },
+12 -1
View File
@@ -1,3 +1,4 @@
import {isDidString} from '@atproto/lex'
import {z} from 'zod' import {z} from 'zod'
import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales'
@@ -13,7 +14,17 @@ const externalEmbedOptions = ['show', 'hide'] as const
*/ */
const accountSchema = z.object({ const accountSchema = z.object({
service: z.string(), service: z.string(),
did: z.string(), /**
* Genuinely validated, not just branded: the refinement rejects malformed
* values at runtime and narrows the inferred type to `DidString`.
*
* Weigh any further tightening of this field carefully. One failing field
* fails the whole root schema, and {@link tryParse} then discards the ENTIRE
* persisted state - every account and every preference - so the app boots
* logged out with defaults. Persisted dids come from com.atproto.server
* responses and are always canonical, so this particular check is safe.
*/
did: z.string().refine(isDidString),
handle: z.string(), handle: z.string(),
email: z.string().optional(), email: z.string().optional(),
emailConfirmed: z.boolean().optional(), emailConfirmed: z.boolean().optional(),
+5 -5
View File
@@ -39,11 +39,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return undefined return undefined
} }
return { return {
/* userDid,
* `did`/`hiddenPosts` come from persisted storage typed as plain
* `string`, so brand them to the SDK's `DidString`/`AtUriString` slots.
*/
userDid: userDid as ModerationOpts['userDid'],
prefs: { prefs: {
...moderationPrefs, ...moderationPrefs,
labelers: moderationPrefs.labelers.length labelers: moderationPrefs.labelers.length
@@ -52,6 +48,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
did, did,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})), })),
/*
* `hiddenPosts` comes from persisted storage typed as plain `string`,
* so brand it to the SDK's `AtUriString` slot.
*/
hiddenPosts: (hiddenPosts || hiddenPosts: (hiddenPosts ||
[]) as ModerationOpts['prefs']['hiddenPosts'], []) as ModerationOpts['prefs']['hiddenPosts'],
}, },
+2 -4
View File
@@ -1,4 +1,3 @@
import {type AtIdentifierString} from '@atproto/syntax'
import {t} from '@lingui/core/macro' import {t} from '@lingui/core/macro'
import { import {
type InfiniteData, type InfiniteData,
@@ -41,8 +40,7 @@ export function useNotificationDeclarationQuery() {
queryFn: async () => { queryFn: async () => {
try { try {
const response = await client.get(app.bsky.notification.declaration, { const response = await client.get(app.bsky.notification.declaration, {
// the session account is still legacy-typed, so its did is unbranded repo: currentAccount!.did,
repo: currentAccount!.did as AtIdentifierString,
rkey: 'self', rkey: 'self',
}) })
return response return response
@@ -72,7 +70,7 @@ export function useNotificationDeclarationMutation() {
app.bsky.notification.declaration, app.bsky.notification.declaration,
record, record,
{ {
repo: currentAccount!.did as AtIdentifierString, repo: currentAccount!.did,
rkey: 'self', rkey: 'self',
}, },
) )
@@ -1,5 +1,5 @@
import {type $Typed} from '@atproto/lex' import {type $Typed} from '@atproto/lex'
import {AtUri} from '@atproto/syntax' import {AtUri, toDatetimeString} from '@atproto/syntax'
import { import {
type InfiniteData, type InfiniteData,
type QueryClient, type QueryClient,
@@ -71,19 +71,14 @@ export async function optimisticallySaveBookmark(
pages: data.pages.map((page, index) => { pages: data.pages.map((page, index) => {
if (index === 0) { if (index === 0) {
post.$type = 'app.bsky.feed.defs#postView' post.$type = 'app.bsky.feed.defs#postView'
/* const bookmark: app.bsky.bookmark.defs.BookmarkView = {
* The optimistic entry is synthesized with unbranded string createdAt: toDatetimeString(new Date()),
* fields, so it is asserted to the generated view type the query
* data is keyed on.
*/
const bookmark = {
createdAt: new Date().toISOString(),
subject: { subject: {
uri: post.uri, uri: post.uri,
cid: post.cid, cid: post.cid,
}, },
item: post as $Typed<app.bsky.feed.defs.PostView>, item: post as $Typed<app.bsky.feed.defs.PostView>,
} as unknown as app.bsky.bookmark.defs.BookmarkView }
return { return {
...page, ...page,
bookmarks: [bookmark, ...page.bookmarks], bookmarks: [bookmark, ...page.bookmarks],
+3 -3
View File
@@ -44,8 +44,8 @@ export function useListMembershipAddMutation({
throw new Error('Not signed in') throw new Error('Not signed in')
} }
/* /*
* The mutation's inputs are plain strings held by legacy-typed views, so * The mutation takes the list uri and actor did as plain strings, so they
* they are asserted to their branded forms here. * are asserted to their branded forms here.
*/ */
const res = await pdsClient.create(app.bsky.graph.listitem, { const res = await pdsClient.create(app.bsky.graph.listitem, {
subject: actorDid as DidString, subject: actorDid as DidString,
@@ -145,7 +145,7 @@ export function useListMembershipRemoveMutation({
} }
const membershipUrip = new AtUri(membershipUri) const membershipUrip = new AtUri(membershipUri)
await pdsClient.delete(app.bsky.graph.listitem, { await pdsClient.delete(app.bsky.graph.listitem, {
repo: currentAccount.did as DidString, repo: currentAccount.did,
rkey: membershipUrip.rkeySafe, rkey: membershipUrip.rkeySafe,
}) })
}, },
+3 -9
View File
@@ -1,10 +1,5 @@
import {type $Typed, type Client} from '@atproto/lex' import {type $Typed, type Client} from '@atproto/lex'
import { import {AtUri, type AtUriString, toDatetimeString} from '@atproto/syntax'
type AtIdentifierString,
AtUri,
type AtUriString,
toDatetimeString,
} from '@atproto/syntax'
import { import {
blockActorList, blockActorList,
muteActorList, muteActorList,
@@ -194,8 +189,7 @@ export function useListDeleteMutation() {
let listitemRecordUris: string[] = [] let listitemRecordUris: string[] = []
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
const res = await pdsClient.list(app.bsky.graph.listitem, { const res = await pdsClient.list(app.bsky.graph.listitem, {
// the session account is still legacy-typed, so its did is unbranded repo: currentAccount.did,
repo: currentAccount.did as AtIdentifierString,
cursor, cursor,
limit: 100, limit: 100,
}) })
@@ -228,7 +222,7 @@ export function useListDeleteMutation() {
// apply in chunks // apply in chunks
for (const writesChunk of chunk(writes, 10)) { for (const writesChunk of chunk(writes, 10)) {
await pdsClient.call(com.atproto.repo.applyWrites, { await pdsClient.call(com.atproto.repo.applyWrites, {
repo: currentAccount.did as AtIdentifierString, repo: currentAccount.did,
writes: writesChunk, writes: writesChunk,
}) })
} }
+4 -9
View File
@@ -1,4 +1,4 @@
import {type AtIdentifierString} from '@atproto/syntax' import {type AtIdentifierString, type AtUriString} from '@atproto/syntax'
import { import {
type InfiniteData, type InfiniteData,
type QueryClient, type QueryClient,
@@ -72,18 +72,13 @@ export function updateListMembershipOptimistically({
...page, ...page,
listsWithMembership: page.listsWithMembership.map(lwm => { listsWithMembership: page.listsWithMembership.map(lwm => {
if (lwm.list.uri === listUri) { if (lwm.list.uri === listUri) {
/*
* Callers hand over a plain at-uri and an old-world ProfileView,
* whose `uri`/`did` are unbranded strings. They are runtime
* identical to the generated branded forms, so the synthesised
* listItem is asserted rather than re-validated.
*/
return { return {
...lwm, ...lwm,
listItem: { listItem: {
uri: membershipUri, // callers hand over the membership uri as a plain string
uri: membershipUri as AtUriString,
subject, subject,
} as app.bsky.graph.defs.ListItemView, },
} }
} }
return lwm return lwm
@@ -40,8 +40,7 @@ export function useUpdateActorDeclaration({
current?.associated?.chat?.allowGroupInvites, current?.associated?.chat?.allowGroupInvites,
}) })
const result = await pdsClient.call(com.atproto.repo.putRecord, { const result = await pdsClient.call(com.atproto.repo.putRecord, {
// the session account is still legacy-typed, so its did is unbranded repo: currentAccount.did,
repo: currentAccount.did as DidString,
collection: 'chat.bsky.actor.declaration', collection: 'chat.bsky.actor.declaration',
rkey: 'self', rkey: 'self',
record: { record: {
@@ -106,7 +105,7 @@ export function useDeleteActorDeclaration() {
mutationFn: async () => { mutationFn: async () => {
if (!currentAccount) throw new Error('Not signed in') if (!currentAccount) throw new Error('Not signed in')
const result = await pdsClient.call(com.atproto.repo.deleteRecord, { const result = await pdsClient.call(com.atproto.repo.deleteRecord, {
repo: currentAccount.did as DidString, repo: currentAccount.did,
collection: 'chat.bsky.actor.declaration', collection: 'chat.bsky.actor.declaration',
rkey: 'self', rkey: 'self',
}) })
+1 -2
View File
@@ -1,4 +1,3 @@
import {type DidString} from '@atproto/syntax'
import {type QueryClient, useQuery} from '@tanstack/react-query' import {type QueryClient, useQuery} from '@tanstack/react-query'
import {accumulate} from '#/lib/async/accumulate' import {accumulate} from '#/lib/async/accumulate'
@@ -27,7 +26,7 @@ export function useMyListsQuery(filter: MyListsFilter) {
accumulate(cursor => accumulate(cursor =>
client client
.call(app.bsky.graph.getLists, { .call(app.bsky.graph.getLists, {
actor: currentAccount!.did as DidString, actor: currentAccount!.did,
cursor, cursor,
limit: 50, limit: 50,
}) })
-4
View File
@@ -103,10 +103,6 @@ export function useSaveNux() {
return useMutation({ return useMutation({
retry: 3, retry: 3,
mutationFn: async (nux: AppNux) => { mutationFn: async (nux: AppNux) => {
/*
* `serializeAppNux` still returns the legacy `Nux`, whose strings are
* unbranded; it is validated against the same schema the action expects.
*/
await pdsClient.call(upsertNux, serializeAppNux(nux)) await pdsClient.call(upsertNux, serializeAppNux(nux))
// triggers a refetch // triggers a refetch
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
+2 -3
View File
@@ -1,4 +1,3 @@
import {type DidString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
@@ -36,7 +35,7 @@ export function usePinnedPostMutation() {
// get the currently pinned post so we can optimistically remove the pin from it // get the currently pinned post so we can optimistically remove the pin from it
if (!currentAccount) throw new Error('Not signed in') if (!currentAccount) throw new Error('Not signed in')
const profile = await client.call(app.bsky.actor.getProfile, { const profile = await client.call(app.bsky.actor.getProfile, {
actor: currentAccount.did as DidString, actor: currentAccount.did,
}) })
prevPinnedPost = profile.pinnedPost?.uri prevPinnedPost = profile.pinnedPost?.uri
if (prevPinnedPost && prevPinnedPost !== postUri) { if (prevPinnedPost && prevPinnedPost !== postUri) {
@@ -47,7 +46,7 @@ export function usePinnedPostMutation() {
profile, profile,
updates: existing => { updates: existing => {
existing.pinnedPost = pinCurrentPost existing.pinnedPost = pinCurrentPost
? // the caller's uri/cid are unbranded strings ? // the mutation takes the uri/cid as plain strings
({ ({
uri: postUri, uri: postUri,
cid: postCid, cid: postCid,
+2 -2
View File
@@ -8,8 +8,8 @@ export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate'
/** /**
* Create a new {@link app.bsky.feed.postgate.Main}. URIs are accepted as plain * Create a new {@link app.bsky.feed.postgate.Main}. URIs are accepted as plain
* strings (callers hold raw AT-URIs, often from legacy-typed views) and * strings (callers hold raw AT-URIs) and asserted to the branded `AtUriString`
* asserted to the branded `AtUriString` here. * here.
*/ */
export function createPostgateRecord( export function createPostgateRecord(
postgate: Omit< postgate: Omit<
+1 -2
View File
@@ -1,4 +1,3 @@
import {type DidString} from '@atproto/syntax'
import { import {
type InfiniteData, type InfiniteData,
type QueryClient, type QueryClient,
@@ -51,7 +50,7 @@ export function useProfileFollowersQuery(
* is `undefined`, hence the conditional spread. * is `undefined`, hence the conditional spread.
*/ */
return await client.call(app.bsky.graph.getFollowers, { return await client.call(app.bsky.graph.getFollowers, {
actor: (did || '') as DidString, actor: did || '',
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: pageParam, cursor: pageParam,
...(sortParam ? {sort: sortParam} : {}), ...(sortParam ? {sort: sortParam} : {}),
+1 -2
View File
@@ -1,4 +1,3 @@
import {type DidString} from '@atproto/syntax'
import { import {
type InfiniteData, type InfiniteData,
type QueryClient, type QueryClient,
@@ -56,7 +55,7 @@ export function useProfileFollowsQuery(
* is `undefined`, hence the conditional spread. * is `undefined`, hence the conditional spread.
*/ */
return await client.call(app.bsky.graph.getFollows, { return await client.call(app.bsky.graph.getFollows, {
actor: (did || '') as DidString, actor: did || '',
limit: limit || PAGE_SIZE, limit: limit || PAGE_SIZE,
cursor: pageParam, cursor: pageParam,
...(sortParam ? {sort: sortParam} : {}), ...(sortParam ? {sort: sortParam} : {}),
+2 -2
View File
@@ -643,7 +643,7 @@ function useProfileBlockMutation() {
throw new Error('Not signed in') throw new Error('Not signed in')
} }
return await pdsClient.create(app.bsky.graph.block, { return await pdsClient.create(app.bsky.graph.block, {
// the profile view is still legacy-typed, so its did is unbranded // the mutation takes the did as a plain string
subject: did as DidString, subject: did as DidString,
createdAt: toDatetimeString(new Date()), createdAt: toDatetimeString(new Date()),
}) })
@@ -666,7 +666,7 @@ function useProfileUnblockMutation() {
} }
const {rkeySafe: rkey} = new AtUri(blockUri) const {rkeySafe: rkey} = new AtUri(blockUri)
await pdsClient.delete(app.bsky.graph.block, { await pdsClient.delete(app.bsky.graph.block, {
repo: currentAccount.did as AtIdentifierString, repo: currentAccount.did,
rkey, rkey,
}) })
}, },
+3 -3
View File
@@ -253,9 +253,9 @@ export function useEditStarterPackMutation({
list: currentStarterPack.list?.uri, list: currentStarterPack.list?.uri,
/* /*
* Pre-existing quirk preserved verbatim: the edit path writes whole * Pre-existing quirk preserved verbatim: the edit path writes whole
* `GeneratorView`s where the lexicon declares `feedItem` refs. lex * `GeneratorView`s where the lexicon declares `feedItem` refs. The
* types the raw `putRecord` body as a `LexValue`, which the legacy * raw `putRecord` body is typed as a lex `LexValue`, which the view
* view interface does not structurally satisfy, hence the cast. * structurally satisfies, so this passes through unchanged.
*/ */
feeds: feeds, feeds: feeds,
createdAt: currentStarterPack.record.createdAt, createdAt: currentStarterPack.record.createdAt,
+2 -4
View File
@@ -5,10 +5,8 @@ import {app} from '#/lexicons'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
/* /*
* Threadgate VIEWS stay on the legacy client types: they are read-only inputs * The view's `record` is typed as an opaque lex `LexMap`, so it is validated
* from the appview, and branding them here would ripple through every post * against the threadgate record schema before its `allow` rules are read.
* component. Only the threadgate RECORD is migrated, because it is written
* through `com.atproto.repo.putRecord`, whose body is typed as a lex `LexMap`.
*/ */
export function threadgateViewToAllowUISetting( export function threadgateViewToAllowUISetting(
threadgateView: app.bsky.feed.defs.ThreadgateView | undefined, threadgateView: app.bsky.feed.defs.ThreadgateView | undefined,
@@ -22,7 +22,6 @@ export function useVerificationCreateMutation() {
} }
const {uri} = await pdsClient.create(app.bsky.graph.verification, { const {uri} = await pdsClient.create(app.bsky.graph.verification, {
// the profile view is still legacy-typed, so its strings are unbranded
subject: profile.did, subject: profile.did,
createdAt: toDatetimeString(new Date()), createdAt: toDatetimeString(new Date()),
handle: profile.handle, handle: profile.handle,
@@ -50,7 +50,6 @@ export function useVerificationsRemoveMutation() {
}, },
() => { () => {
return appviewClient.call(app.bsky.actor.getProfile, { return appviewClient.call(app.bsky.actor.getProfile, {
// the profile view is still legacy-typed, so its did is unbranded
actor: profile.did ?? '', actor: profile.did ?? '',
}) })
}, },
@@ -173,7 +173,7 @@ function makeBundle(account: SessionAccount): FakeBundle {
refreshJwt: account.refreshJwt ?? '', refreshJwt: account.refreshJwt ?? '',
/* SessionData types these as branded strings; the values are fixtures */ /* SessionData types these as branded strings; the values are fixtures */
handle: account.handle as `${string}.${string}`, handle: account.handle as `${string}.${string}`,
did: account.did as `did:${string}:${string}`, did: account.did,
active: true, active: true,
service: account.service, service: account.service,
}, },
+1 -1
View File
@@ -69,7 +69,7 @@ export function sessionAccountToSessionData(
return { return {
accessJwt: account.accessJwt ?? '', accessJwt: account.accessJwt ?? '',
active: account.active ?? true, active: account.active ?? true,
did: account.did as SessionData['did'], did: account.did,
email: account.email, email: account.email,
emailAuthFactor: account.emailAuthFactor, emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed, emailConfirmed: account.emailConfirmed,
+3 -3
View File
@@ -1,5 +1,5 @@
import {useEffect, useId, useState} from 'react' import {useEffect, useId, useState} from 'react'
import {AtUri} from '@atproto/syntax' import {type AtIdentifierString, AtUri} from '@atproto/syntax'
import {Logger} from '#/logger' import {Logger} from '#/logger'
import {type FeedSourceInfo} from '#/state/queries/feed' import {type FeedSourceInfo} from '#/state/queries/feed'
@@ -82,8 +82,8 @@ export function useUnstablePostSource(key: string) {
*/ */
export function buildPostSourceKey(key: string, handle: string) { export function buildPostSourceKey(key: string, handle: string) {
const urip = new AtUri(key) const urip = new AtUri(key)
// @ts-expect-error TODO new-sdk-migration // the handle arrives as a plain string
urip.host = handle urip.host = handle as AtIdentifierString
return urip.toString() return urip.toString()
} }
+1 -2
View File
@@ -1,7 +1,6 @@
import {useCallback, useEffect, useState} from 'react' import {useCallback, useEffect, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {type DidString} from '@atproto/syntax'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -69,7 +68,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const {accounts} = useSession() const {accounts} = useSession()
const client = useAppviewClient() const client = useAppviewClient()
useEffect(() => { useEffect(() => {
const actors = accounts.map(acc => acc.did as DidString) const actors = accounts.map(acc => acc.did)
if (actors.length === 0) return if (actors.length === 0) return
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: profilesQueryKey(actors), queryKey: profilesQueryKey(actors),
@@ -143,10 +143,6 @@ export function useSaveDraftMutation() {
{appviewClient: client, chatClient}, {appviewClient: client, chatClient},
composerState, composerState,
) )
/*
* `composerStateToDraft` builds the draft with unbranded string fields,
* so it is asserted once here to the generated input type.
*/
const draft = apiDraft const draft = apiDraft
logger.debug('saving draft', { logger.debug('saving draft', {
-8
View File
@@ -321,10 +321,6 @@ export function composerReducer(
posts, posts,
postgate: createPostgateRecord({ postgate: createPostgateRecord({
post: '', post: '',
/*
* Draft records are still typed against the legacy client, so the
* stored rules arrive unbranded. Wave B migrates the draft types.
*/
embeddingRules: postgateEmbeddingRules, embeddingRules: postgateEmbeddingRules,
}), }),
threadgate: threadgateRecordToAllowUISetting({ threadgate: threadgateRecordToAllowUISetting({
@@ -750,10 +746,6 @@ export function createComposerState({
], ],
postgate: createPostgateRecord({ postgate: createPostgateRecord({
post: '', post: '',
/*
* Preferences are still typed against the legacy client, so the stored
* rules arrive unbranded. Wave B migrates `getPreferences`.
*/
embeddingRules: initInteractionSettings?.postgateEmbeddingRules || [], embeddingRules: initInteractionSettings?.postgateEmbeddingRules || [],
}), }),
threadgate: threadgateRecordToAllowUISetting({ threadgate: threadgateRecordToAllowUISetting({
@@ -86,10 +86,6 @@ export function ThreadgateBtn({
$type: 'app.bsky.feed.threadgate', $type: 'app.bsky.feed.threadgate',
post: '' as AtUriString, post: '' as AtUriString,
createdAt: toDatetimeString(new Date()), createdAt: toDatetimeString(new Date()),
/*
* Preferences are still typed against the legacy client, so the stored
* rules arrive unbranded. Wave B migrates `getPreferences`.
*/
allow: preferences?.postInteractionSettings.threadgateAllowRules, allow: preferences?.postInteractionSettings.threadgateAllowRules,
}) })
const prefPostgate = createPostgateRecord({ const prefPostgate = createPostgateRecord({
@@ -9,7 +9,7 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import {TID} from '@atproto/common-web' import {TID} from '@atproto/common-web'
import {AtUri, type DidString} from '@atproto/syntax' import {AtUri} from '@atproto/syntax'
import { import {
moderateProfile, moderateProfile,
type ModerationDecision, type ModerationDecision,
@@ -913,9 +913,7 @@ function SayHelloBtn({profile}: {profile: app.bsky.actor.defs.ProfileView}) {
try { try {
setIsLoading(true) setIsLoading(true)
const data = await client.call(chat.bsky.convo.getConvoForMembers, { const data = await client.call(chat.bsky.convo.getConvoForMembers, {
// both dids are already resolved - one from the profile view, one from members: [profile.did, currentAccount!.did],
// the active session
members: [profile.did, currentAccount!.did] as DidString[],
}) })
navigation.navigate('MessagesConversation', { navigation.navigate('MessagesConversation', {
conversation: data.convo.id, conversation: data.convo.id,
+1 -1
View File
@@ -341,7 +341,7 @@ export const DebugModScreen = ({}: NativeStackScreenProps<
blockingByList: undefined, blockingByList: undefined,
}), }),
}) })
mockedProfile.did = did as app.bsky.actor.defs.ProfileViewBasic['did'] mockedProfile.did = did
mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png' mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png'
// @ts-expect-error ProfileViewBasic is close enough -esb // @ts-expect-error ProfileViewBasic is close enough -esb
mockedProfile.banner = mockedProfile.banner =