@atproto/lexicon-resolver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE.txt +7 -0
  3. package/README.md +79 -0
  4. package/dist/client/index.d.ts +28 -0
  5. package/dist/client/index.d.ts.map +1 -0
  6. package/dist/client/index.js +118 -0
  7. package/dist/client/index.js.map +1 -0
  8. package/dist/client/lexicons.d.ts +105 -0
  9. package/dist/client/lexicons.d.ts.map +1 -0
  10. package/dist/client/lexicons.js +75 -0
  11. package/dist/client/lexicons.js.map +1 -0
  12. package/dist/client/types/com/atproto/sync/getRecord.d.ts +38 -0
  13. package/dist/client/types/com/atproto/sync/getRecord.d.ts.map +1 -0
  14. package/dist/client/types/com/atproto/sync/getRecord.js +58 -0
  15. package/dist/client/types/com/atproto/sync/getRecord.js.map +1 -0
  16. package/dist/client/util.d.ts +37 -0
  17. package/dist/client/util.d.ts.map +1 -0
  18. package/dist/client/util.js +38 -0
  19. package/dist/client/util.js.map +1 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +19 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/lexicon.d.ts +43 -0
  25. package/dist/lexicon.d.ts.map +1 -0
  26. package/dist/lexicon.js +98 -0
  27. package/dist/lexicon.js.map +1 -0
  28. package/dist/record.d.ts +37 -0
  29. package/dist/record.d.ts.map +1 -0
  30. package/dist/record.js +100 -0
  31. package/dist/record.js.map +1 -0
  32. package/dist/util.d.ts +2 -0
  33. package/dist/util.d.ts.map +1 -0
  34. package/dist/util.js +14 -0
  35. package/dist/util.js.map +1 -0
  36. package/jest.config.js +7 -0
  37. package/package.json +46 -0
  38. package/src/client/index.ts +67 -0
  39. package/src/client/lexicons.ts +98 -0
  40. package/src/client/types/com/atproto/sync/getRecord.ts +78 -0
  41. package/src/client/util.ts +82 -0
  42. package/src/index.ts +2 -0
  43. package/src/lexicon.ts +147 -0
  44. package/src/record.ts +156 -0
  45. package/src/util.ts +10 -0
  46. package/tests/lexicon.test.ts +266 -0
  47. package/tests/record.test.ts +98 -0
  48. package/tsconfig.build.json +9 -0
  49. package/tsconfig.build.tsbuildinfo +1 -0
  50. package/tsconfig.json +7 -0
  51. package/tsconfig.tests.json +8 -0
  52. package/tsconfig.tests.tsbuildinfo +1 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * GENERATED CODE - DO NOT MODIFY
3
+ */
4
+
5
+ import { type ValidationResult } from '@atproto/lexicon'
6
+
7
+ export type OmitKey<T, K extends keyof T> = {
8
+ [K2 in keyof T as K2 extends K ? never : K2]: T[K2]
9
+ }
10
+
11
+ export type $Typed<V, T extends string = string> = V & { $type: T }
12
+ export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
13
+
14
+ export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
15
+ ? Id
16
+ : `${Id}#${Hash}`
17
+
18
+ function isObject<V>(v: V): v is V & object {
19
+ return v != null && typeof v === 'object'
20
+ }
21
+
22
+ function is$type<Id extends string, Hash extends string>(
23
+ $type: unknown,
24
+ id: Id,
25
+ hash: Hash,
26
+ ): $type is $Type<Id, Hash> {
27
+ return hash === 'main'
28
+ ? $type === id
29
+ : // $type === `${id}#${hash}`
30
+ typeof $type === 'string' &&
31
+ $type.length === id.length + 1 + hash.length &&
32
+ $type.charCodeAt(id.length) === 35 /* '#' */ &&
33
+ $type.startsWith(id) &&
34
+ $type.endsWith(hash)
35
+ }
36
+
37
+ export type $TypedObject<
38
+ V,
39
+ Id extends string,
40
+ Hash extends string,
41
+ > = V extends {
42
+ $type: $Type<Id, Hash>
43
+ }
44
+ ? V
45
+ : V extends { $type?: string }
46
+ ? V extends { $type?: infer T extends $Type<Id, Hash> }
47
+ ? V & { $type: T }
48
+ : never
49
+ : V & { $type: $Type<Id, Hash> }
50
+
51
+ export function is$typed<V, Id extends string, Hash extends string>(
52
+ v: V,
53
+ id: Id,
54
+ hash: Hash,
55
+ ): v is $TypedObject<V, Id, Hash> {
56
+ return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
57
+ }
58
+
59
+ export function maybe$typed<V, Id extends string, Hash extends string>(
60
+ v: V,
61
+ id: Id,
62
+ hash: Hash,
63
+ ): v is V & object & { $type?: $Type<Id, Hash> } {
64
+ return (
65
+ isObject(v) &&
66
+ ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
67
+ )
68
+ }
69
+
70
+ export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
71
+ export type ValidatorParam<V extends Validator> =
72
+ V extends Validator<infer R> ? R : never
73
+
74
+ /**
75
+ * Utility function that allows to convert a "validate*" utility function into a
76
+ * type predicate.
77
+ */
78
+ export function asPredicate<V extends Validator>(validate: V) {
79
+ return function <T>(v: T): v is T & ValidatorParam<V> {
80
+ return validate(v).success
81
+ }
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './record.js'
2
+ export * from './lexicon.js'
package/src/lexicon.ts ADDED
@@ -0,0 +1,147 @@
1
+ import dns from 'node:dns/promises'
2
+ import { CID } from 'multiformats/cid'
3
+ import { LexiconDoc, parseLexiconDoc } from '@atproto/lexicon'
4
+ import { Commit } from '@atproto/repo'
5
+ import { AtUri, NSID, ensureValidDid } from '@atproto/syntax'
6
+ import {
7
+ BuildRecordResolverOptions,
8
+ ResolveRecordOptions,
9
+ buildRecordResolver,
10
+ } from './record.js'
11
+ import { isValidDid } from './util.js'
12
+
13
+ const DNS_SUBDOMAIN = '_lexicon'
14
+ const DNS_PREFIX = 'did='
15
+ export const LEXICON_SCHEMA_NSID = 'com.atproto.lexicon.schema'
16
+
17
+ /**
18
+ * Resolve Lexicon from an NSID
19
+ */
20
+ export type LexiconResolver = (
21
+ nsid: NSID | string,
22
+ ) => Promise<LexiconResolution>
23
+
24
+ /**
25
+ * Resolve Lexicon from an NSID using Lexicon DID authority and record resolution
26
+ */
27
+ export type AtprotoLexiconResolver = (
28
+ nsid: NSID | string,
29
+ options?: ResolveLexiconOptions,
30
+ ) => Promise<LexiconResolution>
31
+
32
+ export type BuildLexiconResolverOptions = BuildRecordResolverOptions
33
+
34
+ export type ResolveLexiconOptions = ResolveRecordOptions & {
35
+ didAuthority?: string
36
+ }
37
+
38
+ export type LexiconResolution = {
39
+ commit: Commit
40
+ uri: AtUri
41
+ cid: CID
42
+ nsid: NSID
43
+ lexicon: LexiconDoc & LexiconSchemaRecord
44
+ }
45
+
46
+ /**
47
+ * Build a Lexicon resolver function.
48
+ */
49
+ export function buildLexiconResolver(
50
+ options: BuildLexiconResolverOptions = {},
51
+ ): AtprotoLexiconResolver {
52
+ const resolveRecord = buildRecordResolver(options)
53
+ return async function (
54
+ nsidStr: NSID | string,
55
+ opts: ResolveLexiconOptions = {},
56
+ ): Promise<LexiconResolution> {
57
+ const nsid = typeof nsidStr === 'string' ? NSID.parse(nsidStr) : nsidStr
58
+ const didAuthority = await getDidAuthority(nsid, opts)
59
+ const verified = await resolveRecord(
60
+ AtUri.make(didAuthority, LEXICON_SCHEMA_NSID, nsid.toString()),
61
+ { forceRefresh: opts.forceRefresh },
62
+ ).catch((err) => {
63
+ throw new LexiconResolutionError(
64
+ 'Could not resolve Lexicon schema record',
65
+ { cause: err },
66
+ )
67
+ })
68
+ let lexicon: LexiconDoc
69
+ try {
70
+ lexicon = parseLexiconDoc(verified.record)
71
+ } catch (err) {
72
+ throw new LexiconResolutionError('Invalid Lexicon document', {
73
+ cause: err,
74
+ })
75
+ }
76
+ if (!isLexiconSchemaRecord(lexicon)) {
77
+ throw new LexiconResolutionError('Invalid Lexicon schema record')
78
+ }
79
+ if (lexicon.id !== nsid.toString()) {
80
+ throw new LexiconResolutionError(
81
+ `Lexicon schema record id does not match NSID: ${lexicon.id}`,
82
+ )
83
+ }
84
+ const { uri, cid, commit } = verified
85
+ return { commit, uri, cid, nsid, lexicon }
86
+ } satisfies LexiconResolver
87
+ }
88
+
89
+ export const resolveLexicon = buildLexiconResolver()
90
+
91
+ /**
92
+ * Resolve the DID authority for a Lexicon from the network using DNS, based on its NSID.
93
+ * @param nsidStr NSID or string representing one for which to lookup its Lexicon DID authority.
94
+ */
95
+ export async function resolveLexiconDidAuthority(
96
+ nsidStr: NSID | string,
97
+ ): Promise<string | undefined> {
98
+ const nsid = typeof nsidStr === 'string' ? NSID.parse(nsidStr) : nsidStr
99
+ const did = await resolveDns(nsid.authority)
100
+ if (did == null || !isValidDid(did)) return
101
+ return did
102
+ }
103
+
104
+ export class LexiconResolutionError extends Error {
105
+ constructor(message?: string, options?: ErrorOptions) {
106
+ super(message, options)
107
+ this.name = 'LexiconResolutionError'
108
+ }
109
+ }
110
+
111
+ async function getDidAuthority(nsid: NSID, options: ResolveLexiconOptions) {
112
+ if (options.didAuthority) {
113
+ ensureValidDid(options.didAuthority)
114
+ return options.didAuthority
115
+ }
116
+ const did = await resolveLexiconDidAuthority(nsid)
117
+ if (!did) {
118
+ throw new LexiconResolutionError(
119
+ `Could not resolve a DID authority for NSID: ${nsid}`,
120
+ )
121
+ }
122
+ return did
123
+ }
124
+
125
+ async function resolveDns(authority: string): Promise<string | undefined> {
126
+ let chunkedResults: string[][]
127
+ try {
128
+ chunkedResults = await dns.resolveTxt(`${DNS_SUBDOMAIN}.${authority}`)
129
+ } catch (err) {
130
+ return undefined
131
+ }
132
+ return parseDnsResult(chunkedResults)
133
+ }
134
+
135
+ function parseDnsResult(chunkedResults: string[][]): string | undefined {
136
+ const results = chunkedResults.map((chunks) => chunks.join(''))
137
+ const found = results.filter((i) => i.startsWith(DNS_PREFIX))
138
+ if (found.length !== 1) {
139
+ return undefined
140
+ }
141
+ return found[0].slice(DNS_PREFIX.length)
142
+ }
143
+
144
+ type LexiconSchemaRecord = { $type: typeof LEXICON_SCHEMA_NSID }
145
+ function isLexiconSchemaRecord(v: unknown): v is LexiconSchemaRecord {
146
+ return v?.['$type'] === LEXICON_SCHEMA_NSID
147
+ }
package/src/record.ts ADDED
@@ -0,0 +1,156 @@
1
+ import { CID } from 'multiformats/cid'
2
+ import { IdResolver } from '@atproto/identity'
3
+ import { RepoRecord } from '@atproto/lexicon'
4
+ import {
5
+ Commit,
6
+ MST,
7
+ MemoryBlockstore,
8
+ def as repoDef,
9
+ readCarWithRoot,
10
+ verifyCommitSig,
11
+ } from '@atproto/repo'
12
+ import { AtUri, ensureValidDid } from '@atproto/syntax'
13
+ import { BuildFetchHandlerOptions, FetchHandler } from '@atproto/xrpc'
14
+ import { safeFetchWrap } from '@atproto-labs/fetch-node'
15
+ import { AtpBaseClient as Client } from './client/index.js'
16
+ import { isValidDid } from './util.js'
17
+
18
+ /**
19
+ * Resolve a record from the network.
20
+ */
21
+ export type RecordResolver = (
22
+ uriStr: AtUri | string,
23
+ ) => Promise<RecordResolution>
24
+
25
+ /**
26
+ * Resolve a record from the network, verifying its authenticity.
27
+ */
28
+ export type AtprotoRecordResolver = (
29
+ uriStr: AtUri | string,
30
+ options?: ResolveRecordOptions,
31
+ ) => Promise<RecordResolution>
32
+
33
+ export type BuildRecordResolverOptions = {
34
+ idResolver?: IdResolver
35
+ rpc?: Partial<BuildFetchHandlerOptions> | FetchHandler
36
+ }
37
+
38
+ export type ResolveRecordOptions = {
39
+ forceRefresh?: boolean
40
+ }
41
+
42
+ export type RecordResolution = {
43
+ commit: Commit
44
+ uri: AtUri
45
+ cid: CID
46
+ record: RepoRecord
47
+ }
48
+
49
+ /**
50
+ * Build a record resolver function.
51
+ */
52
+ export function buildRecordResolver(
53
+ options: BuildRecordResolverOptions = {},
54
+ ): AtprotoRecordResolver {
55
+ const { idResolver = new IdResolver(), rpc } = options
56
+ return async function resolveRecord(
57
+ uriStr: AtUri | string,
58
+ opts: ResolveRecordOptions = {},
59
+ ): Promise<RecordResolution> {
60
+ const uri = typeof uriStr === 'string' ? new AtUri(uriStr) : uriStr
61
+ const did = await getDidFromUri(uri, { idResolver })
62
+ const identity = await idResolver.did
63
+ .resolveAtprotoData(did, opts.forceRefresh)
64
+ .catch((err) => {
65
+ throw new RecordResolutionError('Could not resolve DID identity data', {
66
+ cause: err,
67
+ })
68
+ })
69
+ const client = new Client(
70
+ typeof rpc === 'function'
71
+ ? rpc
72
+ : {
73
+ ...rpc,
74
+ service: rpc?.service ?? identity.pds,
75
+ fetch: rpc?.fetch ?? safeFetch,
76
+ },
77
+ )
78
+ const { data: proofBytes } = await client.com.atproto.sync
79
+ .getRecord({
80
+ did,
81
+ collection: uri.collection,
82
+ rkey: uri.rkey,
83
+ })
84
+ .catch((err) => {
85
+ throw new RecordResolutionError('Could not fetch record proof', {
86
+ cause: err,
87
+ })
88
+ })
89
+ const verified = await verifyRecordProof(proofBytes, {
90
+ uri: AtUri.make(did, uri.collection, uri.rkey),
91
+ signingKey: identity.signingKey,
92
+ })
93
+ return verified
94
+ }
95
+ }
96
+
97
+ export const resolveRecord = buildRecordResolver()
98
+
99
+ export const safeFetch = safeFetchWrap({
100
+ allowIpHost: false,
101
+ allowImplicitRedirect: true,
102
+ responseMaxSize: (1024 + 10) * 1024, // 1MB + 10kB, just a bit larger than max record size
103
+ })
104
+
105
+ export class RecordResolutionError extends Error {
106
+ constructor(message?: string, options?: ErrorOptions) {
107
+ super(message, options)
108
+ this.name = 'RecordResolutionError'
109
+ }
110
+ }
111
+
112
+ async function getDidFromUri(
113
+ uri: AtUri,
114
+ { idResolver }: { idResolver: IdResolver },
115
+ ) {
116
+ if (uri.host.startsWith('did:')) {
117
+ ensureValidDid(uri.host)
118
+ return uri.host
119
+ }
120
+ const resolved = await idResolver.handle.resolve(uri.host)
121
+ if (!resolved || !isValidDid(resolved)) {
122
+ throw new RecordResolutionError('Could not resolve handle found in AT-URI')
123
+ }
124
+ return resolved
125
+ }
126
+
127
+ async function verifyRecordProof(
128
+ proofBytes: Uint8Array,
129
+ { uri, signingKey }: { uri: AtUri; signingKey: string },
130
+ ) {
131
+ const { root, blocks } = await readCarWithRoot(proofBytes).catch((err) => {
132
+ throw new RecordResolutionError('Malformed record proof', { cause: err })
133
+ })
134
+ const blockstore = new MemoryBlockstore(blocks)
135
+ const commit = await blockstore.readObj(root, repoDef.commit).catch((err) => {
136
+ throw new RecordResolutionError('Invalid commit in record proof', {
137
+ cause: err,
138
+ })
139
+ })
140
+ if (commit.did !== uri.host) {
141
+ throw new RecordResolutionError(`Invalid repo did: ${commit.did}`)
142
+ }
143
+ const validSig = await verifyCommitSig(commit, signingKey)
144
+ if (!validSig) {
145
+ throw new RecordResolutionError(
146
+ `Invalid signature on commit: ${root.toString()}`,
147
+ )
148
+ }
149
+ const mst = MST.load(blockstore, commit.data)
150
+ const cid = await mst.get(`${uri.collection}/${uri.rkey}`)
151
+ if (!cid) {
152
+ throw new RecordResolutionError('Record not found in proof')
153
+ }
154
+ const record = await blockstore.readRecord(cid)
155
+ return { commit, uri, cid, record }
156
+ }
package/src/util.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { ensureValidDid } from '@atproto/syntax'
2
+
3
+ export function isValidDid(did: string) {
4
+ try {
5
+ ensureValidDid(did)
6
+ return true
7
+ } catch {
8
+ return false
9
+ }
10
+ }
@@ -0,0 +1,266 @@
1
+ import { SeedClient, TestNetworkNoAppView, usersSeed } from '@atproto/dev-env'
2
+ import { NSID } from '@atproto/syntax'
3
+ import {
4
+ AtprotoLexiconResolver,
5
+ buildLexiconResolver,
6
+ resolveLexiconDidAuthority,
7
+ } from '../src/index.js'
8
+
9
+ const dnsEntries: [entry: string, ...result: string[][]][] = []
10
+
11
+ jest.mock('node:dns/promises', () => {
12
+ return {
13
+ resolveTxt: (entry: string) => {
14
+ const found = dnsEntries.find(([e]) => e === entry)
15
+ if (found) return found.slice(1)
16
+ return []
17
+ },
18
+ }
19
+ })
20
+
21
+ describe('Lexicon resolution', () => {
22
+ let network: TestNetworkNoAppView
23
+ let sc: SeedClient
24
+ let resolveLexicon: AtprotoLexiconResolver
25
+
26
+ beforeAll(async () => {
27
+ network = await TestNetworkNoAppView.create({
28
+ dbPostgresSchema: 'lex_lexicon_resolution',
29
+ })
30
+ sc = network.getSeedClient()
31
+ await usersSeed(sc)
32
+ dnsEntries.push(['_lexicon.alice.example', [`did=${sc.dids.alice}`]])
33
+ resolveLexicon = buildLexiconResolver({
34
+ rpc: { fetch },
35
+ idResolver: network.pds.ctx.idResolver,
36
+ })
37
+ })
38
+
39
+ afterAll(async () => {
40
+ jest.unmock('node:dns/promises')
41
+ await network.close()
42
+ })
43
+
44
+ it('resolves Lexicon.', async () => {
45
+ const client = network.pds.getClient()
46
+ const lex = await client.com.atproto.lexicon.schema.create(
47
+ { repo: sc.dids.alice, rkey: 'example.alice.name1' },
48
+ { id: 'example.alice.name1', lexicon: 1, defs: {} },
49
+ sc.getHeaders(sc.dids.alice),
50
+ )
51
+ const result = await resolveLexicon('example.alice.name1', {
52
+ forceRefresh: true,
53
+ })
54
+ expect(result.commit.did).toEqual(sc.dids.alice)
55
+ expect(result.cid.toString()).toEqual(lex.cid)
56
+ expect(result.uri.toString()).toEqual(lex.uri)
57
+ expect(result.nsid.toString()).toEqual('example.alice.name1')
58
+ expect(result.lexicon).toEqual({
59
+ $type: 'com.atproto.lexicon.schema',
60
+ id: 'example.alice.name1',
61
+ lexicon: 1,
62
+ defs: {},
63
+ })
64
+ })
65
+
66
+ it('fails on mismatched id.', async () => {
67
+ const client = network.pds.getClient()
68
+ await client.com.atproto.lexicon.schema.create(
69
+ { repo: sc.dids.alice, rkey: 'example.alice.mismatch' },
70
+ { id: 'example.test1.mismatch.bad', lexicon: 1, defs: {} },
71
+ sc.getHeaders(sc.dids.alice),
72
+ )
73
+ await expect(
74
+ resolveLexicon('example.alice.mismatch', {
75
+ forceRefresh: true,
76
+ }),
77
+ ).rejects.toThrow('Lexicon schema record id does not match NSID')
78
+ })
79
+
80
+ it('fails on missing DNS entry.', async () => {
81
+ const client = network.pds.getClient()
82
+ await client.com.atproto.lexicon.schema.create(
83
+ { repo: sc.dids.bob, rkey: 'example.bob.name' },
84
+ { id: 'example.bob.name', lexicon: 1, defs: {} },
85
+ sc.getHeaders(sc.dids.bob),
86
+ )
87
+ await expect(
88
+ resolveLexicon('example.bob.name', {
89
+ forceRefresh: true,
90
+ }),
91
+ ).rejects.toThrow(
92
+ 'Could not resolve a DID authority for NSID: example.bob.name',
93
+ )
94
+ })
95
+
96
+ it('fails on missing record.', async () => {
97
+ await expect(
98
+ resolveLexicon('example.alice.missing', {
99
+ forceRefresh: true,
100
+ }),
101
+ ).rejects.toThrow('Could not resolve Lexicon schema record')
102
+ })
103
+
104
+ it('fails on bad verification.', async () => {
105
+ const client = network.pds.getClient()
106
+ const alicekey = await network.pds.ctx.actorStore.keypair(sc.dids.alice)
107
+ const bobkey = await network.pds.ctx.actorStore.keypair(sc.dids.bob)
108
+ await client.com.atproto.lexicon.schema.create(
109
+ { repo: sc.dids.alice, rkey: 'example.alice.badsig' },
110
+ { id: 'example.alice.badsig', lexicon: 1, defs: {} },
111
+ sc.getHeaders(sc.dids.alice),
112
+ )
113
+ // switch alice's key away from the one used by her pds
114
+ await network.pds.ctx.plcClient.updateAtprotoKey(
115
+ sc.dids.alice,
116
+ network.pds.ctx.plcRotationKey,
117
+ bobkey.did(),
118
+ )
119
+ await expect(
120
+ resolveLexicon('example.alice.badsig', {
121
+ forceRefresh: true,
122
+ }),
123
+ ).rejects.toThrow(
124
+ expect.objectContaining({
125
+ name: 'LexiconResolutionError',
126
+ message: 'Could not resolve Lexicon schema record',
127
+ cause: expect.objectContaining({
128
+ name: 'RecordResolutionError',
129
+ message: expect.stringContaining('Invalid signature on commit'),
130
+ }),
131
+ }),
132
+ )
133
+ // reset alice's key
134
+ await network.pds.ctx.plcClient.updateAtprotoKey(
135
+ sc.dids.alice,
136
+ network.pds.ctx.plcRotationKey,
137
+ alicekey.did(),
138
+ )
139
+ })
140
+
141
+ it('fails on invalid Lexicon document.', async () => {
142
+ const client = network.pds.getClient()
143
+ await client.com.atproto.lexicon.schema.create(
144
+ { repo: sc.dids.alice, rkey: 'example.alice.baddoc' },
145
+ { id: 'example.alice.baddoc', lexicon: 999, defs: {} },
146
+ sc.getHeaders(sc.dids.alice),
147
+ )
148
+ await expect(
149
+ resolveLexicon('example.alice.baddoc', {
150
+ forceRefresh: true,
151
+ }),
152
+ ).rejects.toThrow(
153
+ expect.objectContaining({
154
+ name: 'LexiconResolutionError',
155
+ message: 'Invalid Lexicon document',
156
+ cause: expect.objectContaining({
157
+ name: 'ZodError',
158
+ }),
159
+ }),
160
+ )
161
+ })
162
+
163
+ it('resolves Lexicon based on override authority.', async () => {
164
+ const client = network.pds.getClient()
165
+ await client.com.atproto.lexicon.schema.create(
166
+ { repo: sc.dids.alice, rkey: 'example.alice.override' },
167
+ {
168
+ id: 'example.alice.override',
169
+ lexicon: 1,
170
+ defs: { alice: { type: 'string' } },
171
+ },
172
+ sc.getHeaders(sc.dids.alice),
173
+ )
174
+ const carolLex = await client.com.atproto.lexicon.schema.create(
175
+ { repo: sc.dids.carol, rkey: 'example.alice.override' },
176
+ {
177
+ id: 'example.alice.override',
178
+ lexicon: 1,
179
+ defs: { carol: { type: 'string' } },
180
+ },
181
+ sc.getHeaders(sc.dids.carol),
182
+ )
183
+ const result = await resolveLexicon('example.alice.override', {
184
+ didAuthority: sc.dids.carol,
185
+ forceRefresh: true,
186
+ })
187
+ expect(result.commit.did).toEqual(sc.dids.carol)
188
+ expect(result.cid.toString()).toEqual(carolLex.cid)
189
+ expect(result.uri.toString()).toEqual(carolLex.uri)
190
+ expect(result.nsid.toString()).toEqual('example.alice.override')
191
+ expect(result.lexicon).toEqual({
192
+ $type: 'com.atproto.lexicon.schema',
193
+ id: 'example.alice.override',
194
+ lexicon: 1,
195
+ defs: { carol: { type: 'string' } },
196
+ })
197
+ })
198
+
199
+ describe('DID authority', () => {
200
+ it('handles a simple DNS resolution', async () => {
201
+ dnsEntries.push(['_lexicon.simple.test', ['did=did:example:simpleDid']])
202
+ const did = await resolveLexiconDidAuthority('test.simple.name')
203
+ expect(did).toBe('did:example:simpleDid')
204
+ })
205
+
206
+ it('handles a noisy DNS resolution', async () => {
207
+ dnsEntries.push([
208
+ '_lexicon.noisy.test',
209
+ ['blah blah blah'],
210
+ ['did:example:fakeDid'],
211
+ ['atproto=did:example:fakeDid'],
212
+ ['did=did:example:noisyDid'],
213
+ [
214
+ 'chunk long domain aspdfoiuwerpoaisdfupasodfiuaspdfoiuasdpfoiausdfpaosidfuaspodifuaspdfoiuasdpfoiasudfpasodifuaspdofiuaspdfoiuasd',
215
+ 'apsodfiuweproiasudfpoasidfu',
216
+ ],
217
+ ])
218
+ const did = await resolveLexiconDidAuthority('test.noisy.name')
219
+ expect(did).toBe('did:example:noisyDid')
220
+ })
221
+
222
+ it('handles a bad DNS resolution', async () => {
223
+ dnsEntries.push([
224
+ '_lexicon.bad.test',
225
+ ['blah blah blah'],
226
+ ['did:example:fakeDid'],
227
+ ['atproto=did:example:fakeDid'],
228
+ [
229
+ 'chunk long domain aspdfoiuwerpoaisdfupasodfiuaspdfoiuasdpfoiausdfpaosidfuaspodifuaspdfoiuasdpfoiasudfpasodifuaspdofiuaspdfoiuasd',
230
+ 'apsodfiuweproiasudfpoasidfu',
231
+ ],
232
+ ])
233
+ const did = await resolveLexiconDidAuthority('test.bad.name')
234
+ expect(did).toBeUndefined()
235
+ })
236
+
237
+ it('throws on multiple dids under same domain', async () => {
238
+ dnsEntries.push([
239
+ '_lexicon.bad.test',
240
+ ['did=did:example:firstDid'],
241
+ ['did=did:example:secondDid'],
242
+ ])
243
+ const did = await resolveLexiconDidAuthority('test.multi.name')
244
+ expect(did).toBeUndefined()
245
+ })
246
+
247
+ it('fails on invalid NSID', async () => {
248
+ await expect(resolveLexiconDidAuthority('not an nsid')).rejects.toThrow(
249
+ 'Disallowed characters in NSID',
250
+ )
251
+ })
252
+
253
+ it('fails on invalid DID result', async () => {
254
+ dnsEntries.push(['_lexicon.invalid.test', ['did=not:a:did']])
255
+ const did = await resolveLexiconDidAuthority('test.invalid.name')
256
+ expect(did).toBeUndefined()
257
+ })
258
+
259
+ it('accepts NSID object', async () => {
260
+ const did = await resolveLexiconDidAuthority(
261
+ NSID.parse('test.simple.name'),
262
+ )
263
+ expect(did).toBe('did:example:simpleDid')
264
+ })
265
+ })
266
+ })