From 0a8581fc4fc266e9d20f85e04263e452cd56496e Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Wed, 21 Oct 2026 13:53:21 +0200 Subject: [PATCH] graph: verify and unverify accounts verify writes an app.bsky.graph.verification naming each account's current handle and display name, which the app view requires to match. One that still matches is left alone; one that has fallen out of date is replaced in a single applyWrites. What you have already issued is read from your own repository, since the app view may not list verifications from accounts it does not trust. When it does not list you as a trusted verifier, verify still writes, and says the verification will not show. unverify deletes every verification you issued for each account. The mock PDS gains applyWrites, listRecords, display names and a trusted-verifier status for the end-to-end test. --- README.md | 5 ++- skills/atcli/SKILL.md | 1 + skills/atcli/reference.md | 26 +++++++++++- src/commands/graph.ts | 85 +++++++++++++++++++++++++++++++++++++++ test/e2e.test.ts | 30 ++++++++++++++ test/mock-pds.ts | 28 ++++++++++++- 6 files changed, 171 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index aadd924..10c621d 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ Most commands only read. The ones that write: change the account: `post`, `reply`, `quote`, `delete`, `like`/`unlike`, `repost`/`unrepost`, `bookmark add`/`remove`, `gate`, `profile update`/`pin`/`unpin`, `follow`/`unfollow`, `block`/`unblock`, - `mute`/`unmute`, `list create`/`edit`/`delete`/`add`/`remove` and the - moderation-list actions, `feed save`/`unsave`/`pin`/`unpin`, + `verify`/`unverify`, `mute`/`unmute`, `list + create`/`edit`/`delete`/`add`/`remove` and the moderation-list actions, + `feed save`/`unsave`/`pin`/`unpin`, `notifications seen`, `chat send` and every chat-management command, `record create`/`put`/`delete`, `upload`, `report`. - **On this machine only** — sessions and settings: `login`, `logout`, diff --git a/skills/atcli/SKILL.md b/skills/atcli/SKILL.md index 8fe4d96..ddd2ab6 100644 --- a/skills/atcli/SKILL.md +++ b/skills/atcli/SKILL.md @@ -63,6 +63,7 @@ user asked for that exact action: | `profile pin` / `profile unpin` | pin or unpin a post on your profile | | `follow` / `unfollow` | follow or unfollow accounts | | `block` / `unblock` | block or unblock accounts | +| `verify` / `unverify` | verify accounts as a trusted verifier, or take it back | | `mute` / `unmute` | mute or unmute accounts, threads or words | | `list create` / `list edit` / `list delete` / `list add` / `list remove` | make and change lists | | `list mute` / `list unmute` / `list block` / `list unblock` | apply a moderation list wholesale | diff --git a/skills/atcli/reference.md b/skills/atcli/reference.md index 58437de..4c32c45 100644 --- a/skills/atcli/reference.md +++ b/skills/atcli/reference.md @@ -890,6 +890,30 @@ atcli blocks [options] - `--cursor `: continue from where a previous listing stopped (its "cursor") - `--lists`: list the moderation lists you block instead +### `atcli verify` + +verify accounts, as a trusted verifier. + +**Writes to the network.** It changes your account, or publishes something other people can see. + +``` +atcli verify ... +``` + +- `...`: handles or DIDs + +### `atcli unverify` + +take back your verification of accounts. + +**Writes to the network.** It changes your account, or publishes something other people can see. + +``` +atcli unverify ... +``` + +- `...`: handles or DIDs + ### `atcli mute` mute accounts, threads or words. @@ -1722,7 +1746,7 @@ which atcli this is, and where it keeps things. atcli version ``` -- `atcli feed save`, `atcli feed unsave`, `atcli feed pin`, `atcli feed unpin`, `atcli notifications seen`, `atcli post`, `atcli reply`, `atcli quote`, `atcli delete`, `atcli like`, `atcli unlike`, `atcli repost`, `atcli unrepost`, `atcli bookmark add`, `atcli bookmark remove`, `atcli gate`, `atcli profile update`, `atcli profile pin`, `atcli profile unpin`, `atcli follow`, `atcli unfollow`, `atcli block`, `atcli unblock`, `atcli mute`, `atcli unmute`, `atcli list create`, `atcli list edit`, `atcli list delete`, `atcli list add`, `atcli list remove`, `atcli list mute`, `atcli list unmute`, `atcli list block`, `atcli list unblock`, `atcli chat send`, `atcli chat mark-read`, `atcli chat delete`, `atcli chat react`, `atcli chat unreact`, `atcli chat accept`, `atcli chat mute`, `atcli chat unmute`, `atcli chat leave`, `atcli chat group create`, `atcli chat group add`, `atcli chat group remove`, `atcli chat group rename`, `atcli record create`, `atcli record put`, `atcli record delete`, `atcli xrpc`, `atcli upload`, `atcli report`. +- `atcli feed save`, `atcli feed unsave`, `atcli feed pin`, `atcli feed unpin`, `atcli notifications seen`, `atcli post`, `atcli reply`, `atcli quote`, `atcli delete`, `atcli like`, `atcli unlike`, `atcli repost`, `atcli unrepost`, `atcli bookmark add`, `atcli bookmark remove`, `atcli gate`, `atcli profile update`, `atcli profile pin`, `atcli profile unpin`, `atcli follow`, `atcli unfollow`, `atcli block`, `atcli unblock`, `atcli verify`, `atcli unverify`, `atcli mute`, `atcli unmute`, `atcli list create`, `atcli list edit`, `atcli list delete`, `atcli list add`, `atcli list remove`, `atcli list mute`, `atcli list unmute`, `atcli list block`, `atcli list unblock`, `atcli chat send`, `atcli chat mark-read`, `atcli chat delete`, `atcli chat react`, `atcli chat unreact`, `atcli chat accept`, `atcli chat mute`, `atcli chat unmute`, `atcli chat leave`, `atcli chat group create`, `atcli chat group add`, `atcli chat group remove`, `atcli chat group rename`, `atcli record create`, `atcli record put`, `atcli record delete`, `atcli xrpc`, `atcli upload`, `atcli report`. These write only on this machine — sessions, config, the shell's startup file. Nothing on the network changes: diff --git a/src/commands/graph.ts b/src/commands/graph.ts index 8f9255e..def6556 100644 --- a/src/commands/graph.ts +++ b/src/commands/graph.ts @@ -173,6 +173,89 @@ function relationCommand(name: string, summary: string, collection: string, view }; } +// ----------------------------------------------------------- verification --- + +const VERIFICATION = 'app.bsky.graph.verification'; + +/** + * A verification names the handle and display name its subject had when it was + * made; the app view honours it only while both still match, and only from an + * account it trusts as a verifier. + */ +async function verificationsBy(ctx: Context, did: string): Promise> { + return (await ctx.client.listRecords(did, VERIFICATION, { all: true })).items; +} + +const verify: CommandSpec = { + name: 'verify', + group: PEOPLE, + summary: 'verify accounts, as a trusted verifier', + writes: 'network', + description: ` +Writes a verification naming each account's current handle and display name. +Bluesky shows it only while both stay the same, and only when it trusts you as +a verifier; one that has fallen out of date is replaced.`, + args: [{ name: 'actor', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], + examples: ['atcli verify alice.example.com'], + run: async (ctx) => { + const session = await ctx.client.session('verifying accounts'); + const issuer = await profileOf(ctx.client, session.did); + const trusted = (issuer['verification']?.['trustedVerifierStatus'] as string | undefined) ?? 'none'; + const mine = await verificationsBy(ctx, session.did); + const done: Array<{ did: string; handle: string; displayName: string; uri: string; changed: boolean; replaced: string[] }> = []; + for (const who of ctx.rest('actor')) { + const view = await profileOf(ctx.client, who); + const did = view['did'] as string; + const handle = view['handle'] as string; + const displayName = (view['displayName'] as string | undefined) ?? ''; + if (did === session.did) throw ctx.usage('you cannot verify yourself'); + if (handle === 'handle.invalid') throw ctx.usage(`${who} has no valid handle to verify`); + const existing = mine.filter((each) => each.value['subject'] === did); + const current = existing.find((each) => each.value['handle'] === handle && each.value['displayName'] === displayName); + if (current) { + done.push({ did, handle, displayName, uri: current.uri, changed: false, replaced: [] }); + continue; + } + const response = await ctx.client.pds<{ results: Array<{ uri: string }> }>('com.atproto.repo.applyWrites', { + input: { + repo: session.did, + writes: [ + { $type: 'com.atproto.repo.applyWrites#create', collection: VERIFICATION, value: { $type: VERIFICATION, subject: did, handle, displayName, createdAt: new Date().toISOString() } }, + ...existing.map((each) => ({ $type: 'com.atproto.repo.applyWrites#delete', collection: VERIFICATION, rkey: parseAtUri(each.uri).rkey })), + ], + }, + }); + done.push({ did, handle, displayName, uri: response.results[0]!.uri, changed: true, replaced: existing.map((each) => each.uri) }); + } + const lines = done.map((each) => (!each.changed + ? dim(`already verified: @${each.handle}`) + : `${green('✓')} verified @${each.handle}${each.displayName ? ` (${each.displayName})` : ''}${each.replaced.length ? dim(', replacing an out-of-date verification') : ''}`)); + if (trusted !== 'valid') lines.push(dim(`note: Bluesky does not list @${session.handle} as a trusted verifier, so these will not show as verified`)); + return result({ trustedVerifier: trusted, verified: done }, lines.join('\n')); + }, +}; + +const unverify: CommandSpec = { + name: 'unverify', + group: PEOPLE, + summary: 'take back your verification of accounts', + writes: 'network', + args: [{ name: 'actor', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], + run: async (ctx) => { + const session = await ctx.client.session('removing verifications'); + const mine = await verificationsBy(ctx, session.did); + const done: Array<{ did: string; who: string; removed: string[] }> = []; + for (const who of ctx.rest('actor')) { + const did = await actorDid(who); + const existing = mine.filter((each) => each.value['subject'] === did); + for (const each of existing) await ctx.client.deleteRecord(each.uri); + done.push({ did, who, removed: existing.map((each) => each.uri) }); + } + return result({ unverified: done.map(({ did, removed }) => ({ did, removed, changed: removed.length > 0 })) }, + done.map((each) => (each.removed.length ? `${green('✓')} no longer verifying ${each.who}` : dim(`you had not verified ${each.who}`))).join('\n')); + }, +}; + function actorList(name: string, summary: string, nsid: string, key: string, options: { self?: boolean; aliases?: string[] } = {}): CommandSpec { return { name, @@ -668,6 +751,8 @@ export const graphCommands: CommandSpec[] = [ relationCommand('block', 'block accounts', 'app.bsky.graph.block', 'blocking', false), relationCommand('unblock', 'unblock accounts', 'app.bsky.graph.block', 'blocking', true), blocks, + verify, + unverify, mute, unmute, mutes, diff --git a/test/e2e.test.ts b/test/e2e.test.ts index 128e77c..3daca16 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -250,6 +250,36 @@ test('follow is recorded once, and unfollow removes it', async () => { assert.equal((await run('unfollow', 'bob.test', '--json')).json.changed, true); }); +test('verify names the current handle and display name, replaces out-of-date ones, and unverify removes them', async () => { + const profile = `at://${did('bob.test')}/app.bsky.actor.profile/self`; + const name = (displayName: string) => mock.records.set(profile, { uri: profile, cid: 'bafyprofile', value: { $type: 'app.bsky.actor.profile', displayName } }); + name('Bob'); + const untrusted = await run('verify', 'bob.test'); + assert.equal(untrusted.code, 0, untrusted.stderr); + assert.match(untrusted.stdout, /not list @alice\.test as a trusted verifier/); + + mock.trustedVerifiers.add(did('alice.test')); + const again = await run('verify', 'bob.test', '--json'); + assert.equal(again.json.trustedVerifier, 'valid'); + assert.equal(again.json.verified[0].changed, false); + const first = again.json.verified[0].uri; + assert.deepEqual({ ...mock.records.get(first)?.value, createdAt: undefined }, { $type: 'app.bsky.graph.verification', subject: did('bob.test'), handle: 'bob.test', displayName: 'Bob', createdAt: undefined }); + + name('Robert'); + const renamed = await run('verify', 'bob.test', '--json'); + assert.equal(renamed.json.verified[0].changed, true); + assert.deepEqual(renamed.json.verified[0].replaced, [first]); + assert.equal(mock.records.has(first), false); + assert.equal(mock.records.get(renamed.json.verified[0].uri)?.value['displayName'], 'Robert'); + + assert.equal((await run('verify', 'alice.test', '--json')).code, 2, 'not yourself'); + assert.equal((await run('unverify', 'bob.test', '--json')).json.unverified[0].changed, true); + assert.equal(mock.records.has(renamed.json.verified[0].uri), false); + assert.equal((await run('unverify', 'bob.test', '--json')).json.unverified[0].changed, false); + mock.records.delete(profile); + mock.trustedVerifiers.clear(); +}); + test('delete takes posts by URI, and refuses other people’s', async () => { const posted = await run('post', 'to delete', '--json'); const deleted = await run('delete', posted.json.uri, '--json'); diff --git a/test/mock-pds.ts b/test/mock-pds.ts index ff1277b..0617224 100644 --- a/test/mock-pds.ts +++ b/test/mock-pds.ts @@ -28,6 +28,8 @@ export interface Mock { refreshTokens: Map; /** Make the next access token expire at once. */ expireNextAccessToken: boolean; + /** The accounts the app view trusts to verify others. */ + trustedVerifiers: Set; close(): Promise; } @@ -61,6 +63,7 @@ export async function startMock(accounts: Array<{ handle: string; password: stri url, accounts: accounts.map((account) => ({ ...account, did: fakeDid(account.handle) })), records: new Map(), + trustedVerifiers: new Set(), requests: [], refreshTokens: new Map(), expireNextAccessToken: false, @@ -260,6 +263,24 @@ export async function startMock(accounts: Array<{ handle: string; password: stri mock.records.delete(`at://${self.did}/${body.collection}/${body.rkey}`); return send(200, {}); } + case 'com.atproto.repo.applyWrites': { + if (!self || body.repo !== self.did) return needAuth(); + const results = (body.writes as any[]).map((write) => { + const uri = `at://${self.did}/${write.collection}/${write.rkey ?? nextTid()}`; + if (write.$type.endsWith('#delete')) { + mock.records.delete(uri); + return { $type: 'com.atproto.repo.applyWrites#deleteResult' }; + } + const cid = `bafy${CRYPTO.createHash('sha256').update(JSON.stringify(write.value)).digest('hex').slice(0, 40)}`; + mock.records.set(uri, { uri, cid, value: write.value }); + return { $type: `com.atproto.repo.applyWrites#${write.$type.endsWith('#create') ? 'create' : 'update'}Result`, uri, cid }; + }); + return send(200, { results }); + } + case 'com.atproto.repo.listRecords': { + const prefix = `at://${params.get('repo')}/${params.get('collection')}/`; + return send(200, { records: [...mock.records.values()].filter((each) => each.uri.startsWith(prefix)) }); + } case 'com.atproto.repo.getRecord': { const record = mock.records.get(`at://${params.get('repo')}/${params.get('collection')}/${params.get('rkey')}`); return record ? send(200, record) : fail(400, 'RecordNotFound', 'Could not locate record'); @@ -308,7 +329,12 @@ export async function startMock(accounts: Array<{ handle: string; password: stri const subject = mock.accounts.find((each) => each.did === params.get('actor') || each.handle === params.get('actor')); if (!subject) return fail(400, 'InvalidRequest', 'Profile not found'); const follow = [...mock.records.values()].find((each) => each.value['$type'] === 'app.bsky.graph.follow' && each.value['subject'] === subject.did && each.uri.includes(did ?? '-')); - return send(200, { did: subject.did, handle: subject.handle, viewer: follow ? { following: follow.uri } : {} }); + const displayName = mock.records.get(`at://${subject.did}/app.bsky.actor.profile/self`)?.value['displayName']; + return send(200, { + did: subject.did, handle: subject.handle, ...(displayName ? { displayName } : {}), + verification: { verifications: [], verifiedStatus: 'none', trustedVerifierStatus: mock.trustedVerifiers.has(subject.did) ? 'valid' : 'none' }, + viewer: follow ? { following: follow.uri } : {}, + }); } // Chat: a log of events with ordered revs, and the conversations they are in. case 'chat.bsky.convo.getLog': { -- knotmirror 0.0.1