[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
+12 -1
View File
@@ -1,3 +1,4 @@
import {isDidString} from '@atproto/lex'
import {z} from 'zod'
import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales'
@@ -13,7 +14,17 @@ const externalEmbedOptions = ['show', 'hide'] as const
*/
const accountSchema = z.object({
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(),
email: z.string().optional(),
emailConfirmed: z.boolean().optional(),
+5 -5
View File
@@ -39,11 +39,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return undefined
}
return {
/*
* `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'],
userDid,
prefs: {
...moderationPrefs,
labelers: moderationPrefs.labelers.length
@@ -52,6 +48,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
did,
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 ||
[]) 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 {
type InfiniteData,
@@ -41,8 +40,7 @@ export function useNotificationDeclarationQuery() {
queryFn: async () => {
try {
const response = await client.get(app.bsky.notification.declaration, {
// the session account is still legacy-typed, so its did is unbranded
repo: currentAccount!.did as AtIdentifierString,
repo: currentAccount!.did,
rkey: 'self',
})
return response
@@ -72,7 +70,7 @@ export function useNotificationDeclarationMutation() {
app.bsky.notification.declaration,
record,
{
repo: currentAccount!.did as AtIdentifierString,
repo: currentAccount!.did,
rkey: 'self',
},
)
@@ -1,5 +1,5 @@
import {type $Typed} from '@atproto/lex'
import {AtUri} from '@atproto/syntax'
import {AtUri, toDatetimeString} from '@atproto/syntax'
import {
type InfiniteData,
type QueryClient,
@@ -71,19 +71,14 @@ export async function optimisticallySaveBookmark(
pages: data.pages.map((page, index) => {
if (index === 0) {
post.$type = 'app.bsky.feed.defs#postView'
/*
* The optimistic entry is synthesized with unbranded string
* fields, so it is asserted to the generated view type the query
* data is keyed on.
*/
const bookmark = {
createdAt: new Date().toISOString(),
const bookmark: app.bsky.bookmark.defs.BookmarkView = {
createdAt: toDatetimeString(new Date()),
subject: {
uri: post.uri,
cid: post.cid,
},
item: post as $Typed<app.bsky.feed.defs.PostView>,
} as unknown as app.bsky.bookmark.defs.BookmarkView
}
return {
...page,
bookmarks: [bookmark, ...page.bookmarks],
+3 -3
View File
@@ -44,8 +44,8 @@ export function useListMembershipAddMutation({
throw new Error('Not signed in')
}
/*
* The mutation's inputs are plain strings held by legacy-typed views, so
* they are asserted to their branded forms here.
* The mutation takes the list uri and actor did as plain strings, so they
* are asserted to their branded forms here.
*/
const res = await pdsClient.create(app.bsky.graph.listitem, {
subject: actorDid as DidString,
@@ -145,7 +145,7 @@ export function useListMembershipRemoveMutation({
}
const membershipUrip = new AtUri(membershipUri)
await pdsClient.delete(app.bsky.graph.listitem, {
repo: currentAccount.did as DidString,
repo: currentAccount.did,
rkey: membershipUrip.rkeySafe,
})
},
+3 -9
View File
@@ -1,10 +1,5 @@
import {type $Typed, type Client} from '@atproto/lex'
import {
type AtIdentifierString,
AtUri,
type AtUriString,
toDatetimeString,
} from '@atproto/syntax'
import {AtUri, type AtUriString, toDatetimeString} from '@atproto/syntax'
import {
blockActorList,
muteActorList,
@@ -194,8 +189,7 @@ export function useListDeleteMutation() {
let listitemRecordUris: string[] = []
for (let i = 0; i < 100; i++) {
const res = await pdsClient.list(app.bsky.graph.listitem, {
// the session account is still legacy-typed, so its did is unbranded
repo: currentAccount.did as AtIdentifierString,
repo: currentAccount.did,
cursor,
limit: 100,
})
@@ -228,7 +222,7 @@ export function useListDeleteMutation() {
// apply in chunks
for (const writesChunk of chunk(writes, 10)) {
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: currentAccount.did as AtIdentifierString,
repo: currentAccount.did,
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 {
type InfiniteData,
type QueryClient,
@@ -72,18 +72,13 @@ export function updateListMembershipOptimistically({
...page,
listsWithMembership: page.listsWithMembership.map(lwm => {
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 {
...lwm,
listItem: {
uri: membershipUri,
// callers hand over the membership uri as a plain string
uri: membershipUri as AtUriString,
subject,
} as app.bsky.graph.defs.ListItemView,
},
}
}
return lwm
@@ -40,8 +40,7 @@ export function useUpdateActorDeclaration({
current?.associated?.chat?.allowGroupInvites,
})
const result = await pdsClient.call(com.atproto.repo.putRecord, {
// the session account is still legacy-typed, so its did is unbranded
repo: currentAccount.did as DidString,
repo: currentAccount.did,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
record: {
@@ -106,7 +105,7 @@ export function useDeleteActorDeclaration() {
mutationFn: async () => {
if (!currentAccount) throw new Error('Not signed in')
const result = await pdsClient.call(com.atproto.repo.deleteRecord, {
repo: currentAccount.did as DidString,
repo: currentAccount.did,
collection: 'chat.bsky.actor.declaration',
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 {accumulate} from '#/lib/async/accumulate'
@@ -27,7 +26,7 @@ export function useMyListsQuery(filter: MyListsFilter) {
accumulate(cursor =>
client
.call(app.bsky.graph.getLists, {
actor: currentAccount!.did as DidString,
actor: currentAccount!.did,
cursor,
limit: 50,
})
-4
View File
@@ -103,10 +103,6 @@ export function useSaveNux() {
return useMutation({
retry: 3,
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))
// triggers a refetch
await queryClient.invalidateQueries({
+2 -3
View File
@@ -1,4 +1,3 @@
import {type DidString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
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
if (!currentAccount) throw new Error('Not signed in')
const profile = await client.call(app.bsky.actor.getProfile, {
actor: currentAccount.did as DidString,
actor: currentAccount.did,
})
prevPinnedPost = profile.pinnedPost?.uri
if (prevPinnedPost && prevPinnedPost !== postUri) {
@@ -47,7 +46,7 @@ export function usePinnedPostMutation() {
profile,
updates: existing => {
existing.pinnedPost = pinCurrentPost
? // the caller's uri/cid are unbranded strings
? // the mutation takes the uri/cid as plain strings
({
uri: postUri,
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
* strings (callers hold raw AT-URIs, often from legacy-typed views) and
* asserted to the branded `AtUriString` here.
* strings (callers hold raw AT-URIs) and asserted to the branded `AtUriString`
* here.
*/
export function createPostgateRecord(
postgate: Omit<
+1 -2
View File
@@ -1,4 +1,3 @@
import {type DidString} from '@atproto/syntax'
import {
type InfiniteData,
type QueryClient,
@@ -51,7 +50,7 @@ export function useProfileFollowersQuery(
* is `undefined`, hence the conditional spread.
*/
return await client.call(app.bsky.graph.getFollowers, {
actor: (did || '') as DidString,
actor: did || '',
limit: PAGE_SIZE,
cursor: pageParam,
...(sortParam ? {sort: sortParam} : {}),
+1 -2
View File
@@ -1,4 +1,3 @@
import {type DidString} from '@atproto/syntax'
import {
type InfiniteData,
type QueryClient,
@@ -56,7 +55,7 @@ export function useProfileFollowsQuery(
* is `undefined`, hence the conditional spread.
*/
return await client.call(app.bsky.graph.getFollows, {
actor: (did || '') as DidString,
actor: did || '',
limit: limit || PAGE_SIZE,
cursor: pageParam,
...(sortParam ? {sort: sortParam} : {}),
+2 -2
View File
@@ -643,7 +643,7 @@ function useProfileBlockMutation() {
throw new Error('Not signed in')
}
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,
createdAt: toDatetimeString(new Date()),
})
@@ -666,7 +666,7 @@ function useProfileUnblockMutation() {
}
const {rkeySafe: rkey} = new AtUri(blockUri)
await pdsClient.delete(app.bsky.graph.block, {
repo: currentAccount.did as AtIdentifierString,
repo: currentAccount.did,
rkey,
})
},
+3 -3
View File
@@ -253,9 +253,9 @@ export function useEditStarterPackMutation({
list: currentStarterPack.list?.uri,
/*
* Pre-existing quirk preserved verbatim: the edit path writes whole
* `GeneratorView`s where the lexicon declares `feedItem` refs. lex
* types the raw `putRecord` body as a `LexValue`, which the legacy
* view interface does not structurally satisfy, hence the cast.
* `GeneratorView`s where the lexicon declares `feedItem` refs. The
* raw `putRecord` body is typed as a lex `LexValue`, which the view
* structurally satisfies, so this passes through unchanged.
*/
feeds: feeds,
createdAt: currentStarterPack.record.createdAt,
+2 -4
View File
@@ -5,10 +5,8 @@ import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
/*
* Threadgate VIEWS stay on the legacy client types: they are read-only inputs
* from the appview, and branding them here would ripple through every post
* component. Only the threadgate RECORD is migrated, because it is written
* through `com.atproto.repo.putRecord`, whose body is typed as a lex `LexMap`.
* The view's `record` is typed as an opaque lex `LexMap`, so it is validated
* against the threadgate record schema before its `allow` rules are read.
*/
export function threadgateViewToAllowUISetting(
threadgateView: app.bsky.feed.defs.ThreadgateView | undefined,
@@ -22,7 +22,6 @@ export function useVerificationCreateMutation() {
}
const {uri} = await pdsClient.create(app.bsky.graph.verification, {
// the profile view is still legacy-typed, so its strings are unbranded
subject: profile.did,
createdAt: toDatetimeString(new Date()),
handle: profile.handle,
@@ -50,7 +50,6 @@ export function useVerificationsRemoveMutation() {
},
() => {
return appviewClient.call(app.bsky.actor.getProfile, {
// the profile view is still legacy-typed, so its did is unbranded
actor: profile.did ?? '',
})
},
@@ -173,7 +173,7 @@ function makeBundle(account: SessionAccount): FakeBundle {
refreshJwt: account.refreshJwt ?? '',
/* SessionData types these as branded strings; the values are fixtures */
handle: account.handle as `${string}.${string}`,
did: account.did as `did:${string}:${string}`,
did: account.did,
active: true,
service: account.service,
},
+1 -1
View File
@@ -69,7 +69,7 @@ export function sessionAccountToSessionData(
return {
accessJwt: account.accessJwt ?? '',
active: account.active ?? true,
did: account.did as SessionData['did'],
did: account.did,
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
+3 -3
View File
@@ -1,5 +1,5 @@
import {useEffect, useId, useState} from 'react'
import {AtUri} from '@atproto/syntax'
import {type AtIdentifierString, AtUri} from '@atproto/syntax'
import {Logger} from '#/logger'
import {type FeedSourceInfo} from '#/state/queries/feed'
@@ -82,8 +82,8 @@ export function useUnstablePostSource(key: string) {
*/
export function buildPostSourceKey(key: string, handle: string) {
const urip = new AtUri(key)
// @ts-expect-error TODO new-sdk-migration
urip.host = handle
// the handle arrives as a plain string
urip.host = handle as AtIdentifierString
return urip.toString()
}