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': {