@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE.txt +7 -0
- package/README.md +79 -0
- package/dist/client/index.d.ts +28 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +118 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/lexicons.d.ts +105 -0
- package/dist/client/lexicons.d.ts.map +1 -0
- package/dist/client/lexicons.js +75 -0
- package/dist/client/lexicons.js.map +1 -0
- package/dist/client/types/com/atproto/sync/getRecord.d.ts +38 -0
- package/dist/client/types/com/atproto/sync/getRecord.d.ts.map +1 -0
- package/dist/client/types/com/atproto/sync/getRecord.js +58 -0
- package/dist/client/types/com/atproto/sync/getRecord.js.map +1 -0
- package/dist/client/util.d.ts +37 -0
- package/dist/client/util.d.ts.map +1 -0
- package/dist/client/util.js +38 -0
- package/dist/client/util.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/lexicon.d.ts +43 -0
- package/dist/lexicon.d.ts.map +1 -0
- package/dist/lexicon.js +98 -0
- package/dist/lexicon.js.map +1 -0
- package/dist/record.d.ts +37 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +100 -0
- package/dist/record.js.map +1 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +14 -0
- package/dist/util.js.map +1 -0
- package/jest.config.js +7 -0
- package/package.json +46 -0
- package/src/client/index.ts +67 -0
- package/src/client/lexicons.ts +98 -0
- package/src/client/types/com/atproto/sync/getRecord.ts +78 -0
- package/src/client/util.ts +82 -0
- package/src/index.ts +2 -0
- package/src/lexicon.ts +147 -0
- package/src/record.ts +156 -0
- package/src/util.ts +10 -0
- package/tests/lexicon.test.ts +266 -0
- package/tests/record.test.ts +98 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
- package/tsconfig.tests.json +8 -0
- 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
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,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
|
+
})
|