From 3e671bdb99c6be0165be793fddbf1817bc513e7d Mon Sep 17 00:00:00 2001 From: Valentin Sundermann Date: Sat, 23 Jun 2018 12:08:09 +0200 Subject: [PATCH 1/6] Rename ProfileZoneFile to NameZoneFile --- src/{ProfileZoneFile.ts => NameZoneFile.ts} | 0 src/index.ts | 2 +- .../{ProfileZoneFile.spec.ts => NameZoneFile.spec.ts} | 0 .../unitTests/{ProfileZoneFile.spec.ts => NameZoneFile.spec.ts} | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename src/{ProfileZoneFile.ts => NameZoneFile.ts} (100%) rename tests/integrationTests/{ProfileZoneFile.spec.ts => NameZoneFile.spec.ts} (100%) rename tests/unitTests/{ProfileZoneFile.spec.ts => NameZoneFile.spec.ts} (100%) diff --git a/src/ProfileZoneFile.ts b/src/NameZoneFile.ts similarity index 100% rename from src/ProfileZoneFile.ts rename to src/NameZoneFile.ts diff --git a/src/index.ts b/src/index.ts index 4748065..430b8bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export * from './auth'; export * from './crypto'; +export * from './NameZoneFile'; export * from './operation'; export * from './profile'; -export * from './ProfileZoneFile'; export * from './storage'; diff --git a/tests/integrationTests/ProfileZoneFile.spec.ts b/tests/integrationTests/NameZoneFile.spec.ts similarity index 100% rename from tests/integrationTests/ProfileZoneFile.spec.ts rename to tests/integrationTests/NameZoneFile.spec.ts diff --git a/tests/unitTests/ProfileZoneFile.spec.ts b/tests/unitTests/NameZoneFile.spec.ts similarity index 100% rename from tests/unitTests/ProfileZoneFile.spec.ts rename to tests/unitTests/NameZoneFile.spec.ts From d6f097c682b1802bebf7194b142f64719cb23e75 Mon Sep 17 00:00:00 2001 From: Valentin Sundermann Date: Sat, 23 Jun 2018 12:09:24 +0200 Subject: [PATCH 2/6] Revamp NameZoneFile --- src/NameZoneFile.ts | 247 ++++++++-------- tests/integrationTests/NameZoneFile.spec.ts | 62 +--- tests/unitTests/NameZoneFile.spec.ts | 311 ++++++++++---------- 3 files changed, 291 insertions(+), 329 deletions(-) diff --git a/src/NameZoneFile.ts b/src/NameZoneFile.ts index 6dccfba..7391674 100644 --- a/src/NameZoneFile.ts +++ b/src/NameZoneFile.ts @@ -1,143 +1,156 @@ +import { BlockstackCoreClient } from 'blockstack-core-client.ts'; import { URL } from 'url'; import { JsonZoneFile, makeZoneFile, parseZoneFile } from 'zone-file'; -import { DebugType, Logger } from './debug'; -import { DidNotSatisfyJsonSchemaError, InvalidParameterError, InvalidProfileTokenError } from './error'; -import { extractProfile } from './profile/jwt'; -import { Person } from './profile/Person'; -import { PersonJson } from './profile/schema/Person.json'; -import { PersonLegacyJson } from './profile/schema/PersonLegacy.json'; -import { ProfileJson } from './profile/schema/Profile.json'; -import { ProfileTokenJson } from './profile/schema/ProfileToken.json'; +import { config } from './config'; +import { InvalidParameterError } from './error'; /** - * Creates an RFC1035-compliant zone file for a profile - * - * @param origin The zone file's origin, usually a Blockstack name - * @param tokenFileUrl The zone file's token file URL, usually pointing to a profile - * @returns An RFC1035-compliant zone file - * @throws InvalidParameterError When the given token file URL is invalid + * Class for a zone file that is used to store (routing) information about a Blockstack name + * (currently only able to store profile token URLs) */ -export function makeProfileZoneFile(origin: string, tokenFileUrl: string): string { - // TODO: Validate origin - // TODO: Implement passing multiple URLs - - try { - const url = new URL(tokenFileUrl); - } catch (error) { - throw new InvalidParameterError( - 'tokenFileUrl', - 'The given token file URL is no valid URL (due the `url` package)', - tokenFileUrl - ); - } - - const zoneFile: JsonZoneFile = { - $origin: origin, - $ttl: 3600, - uri: [ - { - name: '_http._tcp', - priority: 10, - target: tokenFileUrl, - weight: 1 - } - ] - }; +export class NameZoneFile { + // @TODO: fromJSON() and fromString() don't necessarily pick up all information of the zone file + // (everything other than $origin, $ttl and the first URI entry gets dropped) + // Idea: Introduce an attribute that holds the original data the zone file is based on + + /** + * Creates a new [[NameZoneFile]] from JSON + * + * @param json The JSON to create from + * @throws [[InvalidParameterError]] when the given JSON has no `$origin` attribute + * @throws [[InvalidParameterError]] when the given JSON has no `uri` attribute + * @throws [[InvalidParameterError]] when the given JSON has an empty `uri` attribute + */ + public static fromJSON(json: JsonZoneFile): NameZoneFile { + if (json.$origin === undefined) { + throw new InvalidParameterError('json', 'Attribute `$origin` does not exist', json); + } - const zoneFileTemplate = '{$origin}\n{$ttl}\n{uri}\n'; + if (json.uri === undefined) { + throw new InvalidParameterError('json', 'Attribute `uri` does not exist', json); + } - return makeZoneFile(zoneFile, zoneFileTemplate); -} + if (json.uri.length === 0) { + throw new InvalidParameterError('json', 'Attribute `uri` has no elements', json); + } -/** - * Extracts a token file URL from a given zone file - * - * @param zoneFileJson The zone file to extract the URL from - * @returns The token file URL from the zone file - * @throws InvalidParameterError When the zone file has no attribute `uri` - * @throws InvalidParameterError When the zone file's `uri` attribute is empty - */ -export function getTokenFileUrl(zoneFileJson: JsonZoneFile): string { - if (zoneFileJson.uri === undefined) { - throw new InvalidParameterError('zoneFileJson', 'Attribute "uri" does not exist', zoneFileJson); + return new NameZoneFile(json.$origin, json.uri[0].target); } - if (zoneFileJson.uri.length === 0) { - throw new InvalidParameterError('zoneFileJson', 'Attribute "uri" has no elements', zoneFileJson); + + /** + * Creates a new [[NameZoneFile]] from an RFC1035-compliant zone file + * + * @param str The string to create from (should be RFC1035-compliant) + */ + public static fromString(str: string): NameZoneFile { + return NameZoneFile.fromJSON(parseZoneFile(str)); } - let tokenFileUrl = zoneFileJson.uri[0].target; + /** + * Looks up the current zone file for a given Blockstack name (please note that this method blindly trusts the connected Blockstack Core node, so make sure it's a trusted one) + * + * @param name The Blockstack name to lookup the zone file for + * @param coreClient The [`BlockstackCoreClient`](https://github.com/ntzwrk/blockstack-core-client.ts) to use, defaults to the one set in ./config + * @returns A promise that resolves to the name's zone file on success and otherwise rejects with an [[Error]] when the profile token has no elements + * + * Please note that this function uses `BlockstackCoreClient.getZoneFile` and [[fromString]], and therefore can also reject with errors from there. + */ + public static async lookupByName( + name: string, + coreClient: BlockstackCoreClient = config.coreClient + ): Promise { + const response = await coreClient.getZoneFile(name); + + // TODO: `blockstack-core-client.ts` should handle this (throw an error) + // Needs proper error responses from Blockstack Core first + if (response.zonefile === undefined) { + throw new Error('Could not find a zone file in response'); + } - // TODO: This probably still works incorrectly with '://' in GET parameters - // (if it's allowed in the specification to pass these unencoded) - if (!tokenFileUrl.includes('://')) { - tokenFileUrl = `https://${tokenFileUrl}`; + return NameZoneFile.fromString(response.zonefile); } - return tokenFileUrl; -} + /** + * The Blockstack name this zone file was created for + */ + public readonly name: string; + + /** + * The profile token URL this zone file was created with + */ + public readonly profileTokenUrl: string; + + /** + * Creates a new [[NameZoneFile]] from a Blockstack name and a profile token URL + * + * @param name The Blockstack name to create this zone file for (will be the zone file's `$origin`) + * @param profileTokenUrl The profile token URL to create this zone file with (will be the `target` of a `uri` element) + * @throws [[InvalidParameterError]] when the given name seems to be invalid (does not include a ".") + * @throws [[InvalidParameterError]] when the given token file URL is invalid + */ + constructor(name: string, profileTokenUrl: string) { + // TODO: This should be able to take multiple profile token URLs + + if (!name.includes('.')) { + throw new InvalidParameterError( + 'name', + 'The given name is no valid Blockstack name (does not include a ".")', + name + ); + } -/** - * Resolves a zone file to a profile JSON object - * - * @param zoneFile The zone file to resolve - * @param publicKeyOrAddress The public key or address who owns it - * @returns A promise containing `ProfileJson`. - * Resolves to a profile JSON object on success. - * Rejects with an `DidNotSatisfyJsonSchemaError`: - * 1) When the retrieved JSON seems to be a [[Person]] but does not satisfy the corresponding JSON schema. - * 2) When the retrieved JSON seems to be a [[PersonLegacy]] but does not satisfy the corresponding JSON schema. - * Rejects with an `InvalidProfileTokenError`: - * 1) When the profile token has no elements, - * 2) When the first element of the profile token has no "token" attribute. - * - * Please note that this function uses `fetch` and therefore can also reject with errors from there. - */ -export async function resolveZoneFileToProfile(zoneFile: string, publicKeyOrAddress: string): Promise { - const zoneFileJson: JsonZoneFile = parseZoneFile(zoneFile); + /* + * This small check is for zone files that have a profile token url without scheme. + * There was a small time period where `blockstack.js` created these zone files, so + * this check might be necessary until they are all revised. + * It probably doesn't work with checking for '://', since it's maybe allowed to use + * it in GET parameters(?). + */ + if (!profileTokenUrl.includes('://')) { + profileTokenUrl = `https://${profileTokenUrl}`; + } - if (zoneFileJson.$origin === undefined) { - let legacyProfileJson: PersonLegacyJson; try { - legacyProfileJson = JSON.parse(zoneFile) as PersonLegacyJson; + const url = new URL(profileTokenUrl); + if (!url.hostname.includes('.')) { + throw new Error(); + } } catch (error) { - throw new DidNotSatisfyJsonSchemaError('PersonLegacy.json', zoneFile); + throw new InvalidParameterError( + 'profileTokenUrl', + 'The given profile token URL is no valid URL (says the `url` package)', + profileTokenUrl + ); } - return Person.fromLegacyFormat(legacyProfileJson).toJSON(); - } - - const tokenFileUrl = getTokenFileUrl(zoneFileJson); - const response = await (await fetch(tokenFileUrl)).text(); - let profileTokenJson: ProfileTokenJson; - try { - profileTokenJson = JSON.parse(response) as ProfileTokenJson; - } catch (error) { - throw new InvalidProfileTokenError(response); + this.name = name; + this.profileTokenUrl = profileTokenUrl; } - if (profileTokenJson.length === 0) { - throw new InvalidProfileTokenError(profileTokenJson, 'The profile token has no elements'); + /** + * Returns a JSON object representing the zone file + */ + public toJSON(): JsonZoneFile { + return { + $origin: this.name, + $ttl: 3600, + uri: [ + { + name: '_http._tcp', + priority: 10, + target: this.profileTokenUrl, + weight: 1 + } + ] + }; } - if (profileTokenJson[0].token === undefined) { - throw new InvalidProfileTokenError( - profileTokenJson, - 'The first element of the profile token has no "token" attrbute' - ); - } - - return extractProfile(profileTokenJson[0].token as string, publicKeyOrAddress); -} -/** - * Resolves a zone file to a person JSON object - * - * @param zoneFile The zone file to resolve - * @param publicKeyOrAddress The public key or address who owns it - * @returns A promise containing `PersonJson` - * - * Please note that this function uses [[resolveZoneFileToProfile]] and therefore rejects with the same errors. - */ -export async function resolveZoneFileToPerson(zoneFile: string, publicKeyOrAddress: string): Promise { - return (await resolveZoneFileToProfile(zoneFile, publicKeyOrAddress)) as PersonJson; + /** + * Returns a RFC1035-compliant zone file + */ + public toString(): string { + const zoneFileTemplate = '{$origin}\n{$ttl}\n{uri}\n'; + return makeZoneFile(this.toJSON(), zoneFileTemplate); + } } diff --git a/tests/integrationTests/NameZoneFile.spec.ts b/tests/integrationTests/NameZoneFile.spec.ts index ecdde00..95b5c16 100644 --- a/tests/integrationTests/NameZoneFile.spec.ts +++ b/tests/integrationTests/NameZoneFile.spec.ts @@ -1,74 +1,32 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; -import * as fs from 'fs'; -import { TokenSigner, JWT } from 'jsontokens'; -import { JsonZoneFile, makeZoneFile, parseZoneFile } from 'zone-file'; import { correct, incorrect } from '../fun'; -import { DidNotSatisfyJsonSchemaError, InvalidParameterError } from '../../src/error'; -import { signProfileToken } from '../../src/profile/jwt'; -import { PersonJson } from '../../src/profile/schema/Person.json'; -import { ProfileJson } from '../../src/profile/schema/Profile.json'; -import { - getTokenFileUrl, - makeProfileZoneFile, - resolveZoneFileToPerson, - resolveZoneFileToProfile -} from '../../src/ProfileZoneFile'; +import { NameZoneFile } from '../../src/NameZoneFile'; chai.use(chaiAsPromised); -describe('zoneFile.ts', () => { - describe('resolveZoneFileToPerson', () => { - it(`makes ${correct()} persons`, async function() { +describe('NameZoneFile', () => { + describe('lookupByName', () => { + it(`looks up ${correct()} names`, async function() { this.slow(3000); this.timeout(6000); this.retries(3); - const zoneFile = - '$ORIGIN vsund.id\n$TTL 3600\n_http._tcp URI 5 1 "https://gaia.blockstack.org/hub/15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f/0/profile.json"\n'; - const address = '15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f'; + const zoneFile = await NameZoneFile.lookupByName('vsund.id'); - const personJson: PersonJson = await resolveZoneFileToPerson(zoneFile, address); - - chai.expect(personJson['@context']).to.equal('http://schema.org'); - chai.expect(personJson['@type']).to.equal('Person'); - chai.expect(personJson.name).to.equal('vsund'); - }); - - it(`fails on ${incorrect()} inputs`, function() { - this.slow(3000); - this.timeout(6000); - this.retries(3); - - chai.expect(resolveZoneFileToPerson('', '')).to.eventually.be.rejectedWith(DidNotSatisfyJsonSchemaError); - }); - }); - - describe('resolveZoneFileToProfile', () => { - it(`makes ${correct()} profiles`, async function() { - this.slow(3000); - this.timeout(6000); - this.retries(3); - - const zoneFile = - '$ORIGIN vsund.id\n$TTL 3600\n_http._tcp URI 5 1 "https://gaia.blockstack.org/hub/15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f/0/profile.json"\n'; - const address = '15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f'; - - const profileJson: ProfileJson = await resolveZoneFileToProfile(zoneFile, address); - - chai.expect(profileJson['@context']).to.equal('http://schema.org'); - chai.expect(profileJson['@type']).to.equal('Person'); - chai.expect(profileJson.name).to.equal('vsund'); + chai.expect(zoneFile.name).to.equal('vsund.id'); + chai.expect(zoneFile.profileTokenUrl).to.contain('/hub/15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f/0/profile.json'); }); - it(`fails on ${incorrect()} inputs`, function() { + it(`fails on ${incorrect()} inputs`, async function() { this.slow(3000); this.timeout(6000); this.retries(3); - chai.expect(resolveZoneFileToProfile('', '')).to.eventually.be.rejectedWith(DidNotSatisfyJsonSchemaError); + // TODO: Revisit this when Blockstack Core responds with proper error messages (`blockstack-core-client.ts` currently returns a plain error for not finding a zone file) + chai.expect(NameZoneFile.lookupByName('please-dont-buy-this-name.id')).to.eventually.be.rejectedWith(Error); }); }); }); diff --git a/tests/unitTests/NameZoneFile.spec.ts b/tests/unitTests/NameZoneFile.spec.ts index 4e5807a..42445e4 100644 --- a/tests/unitTests/NameZoneFile.spec.ts +++ b/tests/unitTests/NameZoneFile.spec.ts @@ -9,74 +9,57 @@ import { correct, incorrect } from '../fun'; import { DidNotSatisfyJsonSchemaError, InvalidParameterError, InvalidProfileTokenError } from '../../src/error'; import { signProfileToken } from '../../src/profile/jwt'; +import { Person } from '../../src/profile/Person'; import { PersonJson } from '../../src/profile/schema/Person.json'; import { ProfileJson } from '../../src/profile/schema/Profile.json'; -import { - getTokenFileUrl, - makeProfileZoneFile, - resolveZoneFileToPerson, - resolveZoneFileToProfile -} from '../../src/ProfileZoneFile'; +import { NameZoneFile } from '../../src/NameZoneFile'; +import { Profile } from '../../src/profile/Profile'; chai.use(chaiAsPromised); -describe('zoneFile.ts', () => { - describe('getTokenFileUrl', () => { - it(`delivers ${correct()} token file URLs`, () => { +// TODO: Add this.slow() to all tests +// TODO: Rename "token file" consistently to "profile token" +// TODO: Combine all re-used variables on a higher layer + +describe('NameZoneFile', () => { + afterEach(() => { + if (!nock.isDone()) { + console.warn( + '\x1b[1m\x1b[2m%s\x1b[0m\x1b[0m', + " nock hasn't fully consumed all mocked responses, clearing them now" + ); + nock.cleanAll(); + } + }); + + describe('fromJSON', () => { + it(`creates ${correct()} zone file objects from JSON`, () => { + const name = 'some-name.id'; const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - const jsonZoneFile: JsonZoneFile = { + const zoneFileJson: JsonZoneFile = { + $origin: name, + $ttl: 3600, uri: [ { - name: 'some-name.id', + name: '_http._tcp', target: tokenFileUrl, - priority: 0, - weight: 0 - } - ] - }; - const jsonZoneFileWithoutProtocol: JsonZoneFile = { - uri: [ - { - name: 'some irrelevant name', - target: 'example.org', - priority: 0, - weight: 0 + priority: 10, + weight: 1 } ] }; - chai.expect(getTokenFileUrl(jsonZoneFile)).to.equal(tokenFileUrl); - chai.expect(getTokenFileUrl(jsonZoneFileWithoutProtocol)).to.equal('https://example.org'); - }); - - it(`fails on ${incorrect()} inputs`, () => { - const jsonZoneFileEmpty: JsonZoneFile = {}; - const jsonZoneFileWithoutUri: JsonZoneFile = { - txt: [ - { - name: 'record', - txt: 'some text' - } - ] - }; - const jsonZoneFileWithEmptyUri: JsonZoneFile = { - uri: [] - }; + const zoneFile = new NameZoneFile(name, tokenFileUrl); - chai.expect(() => getTokenFileUrl(jsonZoneFileEmpty)).to.throw(InvalidParameterError); - chai.expect(() => getTokenFileUrl(jsonZoneFileWithoutUri)).to.throw(InvalidParameterError); - chai.expect(() => getTokenFileUrl(jsonZoneFileWithEmptyUri)).to.throw(InvalidParameterError); + chai.expect(NameZoneFile.fromJSON(zoneFileJson)).to.deep.equal(zoneFile); }); - }); - describe('makeProfileZoneFile', () => { - it(`generates ${correct()} zone files`, () => { + it(`fails on ${incorrect()} inputs`, () => { const name = 'some-name.id'; const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - const jsonZoneFile: JsonZoneFile = { - $origin: name, + const zoneFileJsonWithoutOrigin: JsonZoneFile = { $ttl: 3600, uri: [ { @@ -87,150 +70,158 @@ describe('zoneFile.ts', () => { } ] }; - const zoneFile = makeZoneFile(jsonZoneFile, '{$origin}\n{$ttl}\n{uri}\n'); - const staticZoneFile = - '$ORIGIN some-name.id\n$TTL 3600\n_http._tcp\tIN\tURI\t10\t1\t"https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json"\n\n'; + const zoneFileJsonWithoutUri: JsonZoneFile = { + $origin: name, + $ttl: 3600 + }; + const zoneFileJsonWithEmptyUri: JsonZoneFile = { + $origin: name, + $ttl: 3600, + uri: [] + }; - chai.expect(makeProfileZoneFile(name, tokenFileUrl)).to.equal(zoneFile); - chai.expect(makeProfileZoneFile(name, tokenFileUrl)).to.equal(staticZoneFile); - }); - - it(`fails on ${incorrect()} inputs`, () => { - chai.expect(() => makeProfileZoneFile('some-name.id', '')).to.throw(InvalidParameterError); + chai.expect(() => NameZoneFile.fromJSON({})).to.throw(InvalidParameterError); + chai.expect(() => NameZoneFile.fromJSON(zoneFileJsonWithoutOrigin)).to.throw(InvalidParameterError); + chai.expect(() => NameZoneFile.fromJSON(zoneFileJsonWithoutUri)).to.throw(InvalidParameterError); + chai.expect(() => NameZoneFile.fromJSON(zoneFileJsonWithEmptyUri)).to.throw(InvalidParameterError); }); }); - describe('resolveZoneFileToPerson', () => { - it(`makes ${correct()} persons`, async function() { - // TODO: Add tests for legacy zone files - // TODO: Add tests for valid profiles which token is expired + describe('fromString', () => { + it(`creates ${correct()} zone file objects from strings`, () => { + const name = 'some-name.id'; + const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - this.slow(300); + const zoneFileString = `$ORIGIN ${name}\n$TTL 3600\n_http._tcp\tIN\tURI\t10\t1\t"${tokenFileUrl}"\n\n`; - const algorithm = 'ES256K'; - const publicKey = '03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479'; - const privateKey = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; - const tokenSigner = new TokenSigner(algorithm, privateKey); + const zoneFile = new NameZoneFile(name, tokenFileUrl); + + chai.expect(NameZoneFile.fromString(zoneFileString)).to.deep.equal(zoneFile); + }); + it(`fails on ${incorrect()} strings`, () => { const name = 'some-name.id'; const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - const personJson: PersonJson = { - '@context': 'http://schema.org', - '@type': 'Person', - '@id': name, - name: 'John Doe' - }; - const signedToken = signProfileToken(personJson, privateKey); + const zoneFileStringWithoutOrigin = `$TTL 3600\n_http._tcp\tIN\tURI\t10\t1\t"${tokenFileUrl}"\n\n`; + const zoneFileStringWithoutUri = `$ORIGIN ${name}\n$TTL 3600\n\n`; + const zoneFileStringWithEmptyUri = `$ORIGIN ${name}\n$TTL 3600\n\n`; + + chai.expect(() => NameZoneFile.fromString('')).to.throw(InvalidParameterError); + chai.expect(() => NameZoneFile.fromString(zoneFileStringWithoutOrigin)).to.throw(InvalidParameterError); + chai.expect(() => NameZoneFile.fromString(zoneFileStringWithoutUri)).to.throw(InvalidParameterError); + chai.expect(() => NameZoneFile.fromString(zoneFileStringWithEmptyUri)).to.throw(InvalidParameterError); + }); + }); + + describe('lookupByName', () => { + it(`resolves names to ${correct()} zone file objects`, async function() { + const name = 'some-name.id'; + const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - nock('https://gaia.blockstack.org') - .get('/hub/1111111111111111111114oLvT2/0/profile.json') - .reply(200, `[{ "token": "${signedToken}" }]`); + const zoneFile = new NameZoneFile(name, tokenFileUrl); - const zoneFile = makeProfileZoneFile(name, tokenFileUrl); + nock('https://core.blockstack.org') + .get(`/v1/names/${name}/zonefile`) + .reply(200, { zonefile: zoneFile.toString() }); - chai.expect(await resolveZoneFileToPerson(zoneFile, publicKey)).to.deep.equal(personJson); + chai.expect(await NameZoneFile.lookupByName(name)).to.deep.equal(zoneFile); }); - it(`fails on ${incorrect()} inputs`, async function() { - this.slow(200); + it(`fails on ${incorrect()} names`, async function() { + const name = 'some-name.id'; + const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - const algorithm = 'ES256K'; - const publicKey = '03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479'; - const privateKey = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; - const tokenSigner = new TokenSigner(algorithm, privateKey); + const zoneFile = new NameZoneFile(name, tokenFileUrl); + nock('https://core.blockstack.org') + .get(`/v1/names/${name}/zonefile`) + .reply(200, {}); + nock('https://core.blockstack.org') + .get(`/v1/names/${name}/zonefile`) + .reply(200, { zonefile: '' }); + nock('https://core.blockstack.org') + .get(`/v1/names/${name}/zonefile`) + .reply(200, { zonefile: 'some gibberish' + zoneFile.toString() }); + + chai.expect(NameZoneFile.lookupByName(name)).to.eventually.be.rejectedWith(Error); // TODO: `blockstack-core-client.ts` should throw an error this + chai.expect(NameZoneFile.lookupByName(name)).to.eventually.be.rejectedWith(InvalidParameterError); + chai.expect(NameZoneFile.lookupByName(name)).to.eventually.be.rejectedWith(InvalidParameterError); + }); + }); + describe('constructor', () => { + it(`creates ${correct()} zone file objects`, () => { const name = 'some-name.id'; const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - const tokenFileUrlForInvalidTokenFile = - 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/1/profile.json'; - const tokenFileUrlForEmptyToken = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/2/profile.json'; - const tokenFileUrlForIncorrectToken = - 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/3/profile.json'; - const tokenFileUrlForUnsignedToken = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/4/profile.json'; - - const personJson: PersonJson = { - '@context': 'http://schema.org', - '@type': 'Person', - '@id': name, - name: 'John Doe' + const tokenFileUrlWithoutProtocol = 'gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; + + const zoneFile = new NameZoneFile(name, tokenFileUrl); + const zoneFileWithoutProtocol = new NameZoneFile(name, tokenFileUrlWithoutProtocol); + + const zoneFileJson: JsonZoneFile = { + $origin: name, + $ttl: 3600, + uri: [ + { + name: '_http._tcp', + target: tokenFileUrl, + priority: 10, + weight: 1 + } + ] }; - const correctlySignedToken = signProfileToken(personJson, privateKey); - const incorrectToken = signProfileToken({ ...personJson, ...{ '@type': undefined } }, privateKey); - const unsignedToken = createUnsecuredToken(personJson); - - nock('https://gaia.blockstack.org') - .get('/hub/1111111111111111111114oLvT2/0/profile.json') - .reply(200, `[{ "token": "${correctlySignedToken}" }]`); - nock('https://gaia.blockstack.org') - .get('/hub/1111111111111111111114oLvT2/1/profile.json') - .reply(200, ''); - nock('https://gaia.blockstack.org') - .get('/hub/1111111111111111111114oLvT2/2/profile.json') - .reply(200, `[{ "token": "" }]`); - nock('https://gaia.blockstack.org') - .get('/hub/1111111111111111111114oLvT2/3/profile.json') - .reply(200, `[{ "token": "${incorrectToken}" }]`); - nock('https://gaia.blockstack.org') - .get('/hub/1111111111111111111114oLvT2/4/profile.json') - .reply(200, `[{ "token": "${unsignedToken}" }]`); - - const zoneFile = makeProfileZoneFile(name, tokenFileUrl); - const zoneFileWithInvalidTokenFile = makeProfileZoneFile(name, tokenFileUrlForInvalidTokenFile); - const zoneFileWithEmptyToken = makeProfileZoneFile(name, tokenFileUrlForEmptyToken); - const zoneFileWithIncorrectToken = makeProfileZoneFile(name, tokenFileUrlForIncorrectToken); - const zoneFileWithUnsignedToken = makeProfileZoneFile(name, tokenFileUrlForUnsignedToken); - - // TODO: Revisit these when the other functions throw better errors - chai.expect(resolveZoneFileToPerson('', '')).to.eventually.be.rejectedWith(DidNotSatisfyJsonSchemaError); - chai - .expect(resolveZoneFileToPerson(zoneFile, 'some wrong key')) - .to.eventually.be.rejectedWith(InvalidProfileTokenError); - chai - .expect(resolveZoneFileToPerson(zoneFileWithInvalidTokenFile, publicKey)) - .to.eventually.be.rejectedWith(InvalidProfileTokenError); - // chai.expect(resolveZoneFileToPerson(zoneFileWithEmptyToken, publicKey)).to.eventually.be.rejectedWith(InvalidProfileTokenError); - // chai.expect(resolveZoneFileToPerson(zoneFileWithIncorrectToken, publicKey)).to.eventually.be.rejectedWith(InvalidProfileTokenError); - chai - .expect(resolveZoneFileToPerson(zoneFileWithUnsignedToken, publicKey)) - .to.eventually.be.rejectedWith(InvalidProfileTokenError); - }); - }); + const zoneFileFromJson = NameZoneFile.fromJSON(zoneFileJson); - describe('resolveZoneFileToProfile', () => { - it(`makes ${correct()} profiles`, async function() { - // TODO: Add tests for legacy zone files - // TODO: Add tests for valid profiles which token is expired + chai.expect(zoneFile).to.deep.equal(zoneFileWithoutProtocol); + chai.expect(zoneFile).to.deep.equal(zoneFileFromJson); + }); - this.slow(300); + it(`fails on ${incorrect()} inputs`, () => { + const name = 'some-name.id'; + const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; + const invalidName = 'some-name-without-namespace'; + const invalidTokenFileUrl = 'this-is-an-invalid-url'; - const algorithm = 'ES256K'; - const publicKey = '03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479'; - const privateKey = '278a5de700e29faae8e40e366ec5012b5ec63d36ec77e8a2417154cc1d25383f'; - const tokenSigner = new TokenSigner(algorithm, privateKey); + chai.expect(() => new NameZoneFile(invalidName, tokenFileUrl)).to.throw(InvalidParameterError); + chai.expect(() => new NameZoneFile(name, invalidTokenFileUrl)).to.throw(InvalidParameterError); + chai.expect(() => new NameZoneFile(invalidName, invalidTokenFileUrl)).to.throw(InvalidParameterError); + }); + }); + describe('toJSON', () => { + it(`creates ${correct()} JSON objects from zone file objects`, () => { const name = 'some-name.id'; const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; - const profileJson: ProfileJson = { - '@context': 'http://schema.org', - '@type': 'Person', - '@id': name, - name: 'John Doe' + const zoneFileJson: JsonZoneFile = { + $origin: name, + $ttl: 3600, + uri: [ + { + name: '_http._tcp', + target: tokenFileUrl, + priority: 10, + weight: 1 + } + ] }; - const signedToken = signProfileToken(profileJson, privateKey); - nock('https://gaia.blockstack.org') - .get('/hub/1111111111111111111114oLvT2/0/profile.json') - .reply(200, `[{ "token": "${signedToken}" }]`); + const zoneFile = new NameZoneFile(name, tokenFileUrl); - const zoneFile = makeProfileZoneFile(name, tokenFileUrl); - - chai.expect(await resolveZoneFileToPerson(zoneFile, publicKey)).to.deep.equal(profileJson); + chai.expect(zoneFile.toJSON()).to.deep.equal(zoneFileJson); }); + }); + + describe('toString', () => { + it(`creates ${correct()} zone file strings from zone file objects`, () => { + const name = 'some-name.id'; + const tokenFileUrl = 'https://gaia.blockstack.org/hub/1111111111111111111114oLvT2/0/profile.json'; + + const zoneFileString = `$ORIGIN ${name}\n$TTL 3600\n_http._tcp\tIN\tURI\t10\t1\t"${tokenFileUrl}"\n\n`; + + const zoneFile = new NameZoneFile(name, tokenFileUrl); - it(`fails on ${incorrect()} inputs`, async function() { - // TODO: Do these when the used methods throw better errors + chai.expect(zoneFile.toString()).to.equal(zoneFileString); }); }); }); From b98e3ca5e9e623a3ea9bd4553a16527b8b1328d3 Mon Sep 17 00:00:00 2001 From: Valentin Sundermann Date: Sat, 23 Jun 2018 12:14:55 +0200 Subject: [PATCH 3/6] Add quick start for zone files --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index fa684df..3b49ec4 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,14 @@ _Please note that the version on NPM is **just a placeholder** and doesn't conta ### Storage `@todo` +### Zone files +```typescript +import { NameZoneFile } from 'blockstack.ts'; +const zoneFile = await NameZoneFile.lookupByName('vsund.id'); +console.log(zoneFile.profileTokenUrl); +``` +[Full reference for `NameZoneFile`](@todo) + For more examples see [`examples/`](https://github.com/ntzwrk/blockstack.ts/blob/master/examples/). From 10968dc72a755d2f1ec7d0a5dd7f017f57ad6f4c Mon Sep 17 00:00:00 2001 From: Valentin Sundermann Date: Sat, 23 Jun 2018 12:29:11 +0200 Subject: [PATCH 4/6] Add explicitly all attributes for Person --- src/profile/Person.ts | 76 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/src/profile/Person.ts b/src/profile/Person.ts index 6959901..6cce6d8 100644 --- a/src/profile/Person.ts +++ b/src/profile/Person.ts @@ -16,13 +16,48 @@ export interface IVerification { } export class Person extends Profile implements PersonJson { + '@context': string; + '@type': string; + '@id': string; + name?: string; + givenName?: string; + familyName?: string; + description?: string; + image?: ImageJson[]; + website?: WebSiteJson[]; + account?: AccountJson[]; + knows?: BasicJson[]; + worksFor?: BasicJson[]; + address?: PostalAddressJson; + birthDate?: string; + taxID?: string; + apps?: { + [k: string]: string; + }; + public static fromLegacyFormat(legacyProfileJson: PersonLegacyJson) { const profileJson = Person.getPersonFromLegacyFormat(legacyProfileJson); return Person.fromJSON(profileJson); } public static fromJSON(personJson: PersonJson): Person { - return new Person(personJson['@id']); + const person = new Person( + personJson['@id'], + personJson.name, + personJson.givenName, + personJson.familyName, + personJson.description, + personJson.image, + personJson.website, + personJson.account, + personJson.worksFor, + personJson.knows, + personJson.address, + personJson.birthDate, + personJson.taxID, + personJson.apps + ); + return { ...person, ...personJson } as Person; } public static fromToken(token: string, publicKeyOrAddress?: string): Person { @@ -149,20 +184,35 @@ export class Person extends Profile implements PersonJson { constructor( id: string, - public name?: string, - public givenName?: string, - public familyName?: string, - public description?: string, - public image?: ImageJson[], - public website?: WebSiteJson[], - public account?: AccountJson[], - public worksFor?: BasicJson[], - public knows?: BasicJson[], - public address?: PostalAddressJson, - public birthDate?: string, - public taxID?: string + name?: string, + givenName?: string, + familyName?: string, + description?: string, + image?: ImageJson[], + website?: WebSiteJson[], + account?: AccountJson[], + worksFor?: BasicJson[], + knows?: BasicJson[], + address?: PostalAddressJson, + birthDate?: string, + taxID?: string, + apps?: { [k: string]: string } ) { super(id, 'Person'); + + this.name = name; + this.givenName = givenName; + this.familyName = familyName; + this.description = description; + this.image = image; + this.website = website; + this.account = account; + this.worksFor = worksFor; + this.knows = knows; + this.address = address; + this.birthDate = birthDate; + this.taxID = taxID; + this.apps = apps; } public toJSON(): PersonJson { From 099d8cc19539f45cde8e2540806f6aabe0c23b02 Mon Sep 17 00:00:00 2001 From: Valentin Sundermann Date: Sat, 23 Jun 2018 12:29:48 +0200 Subject: [PATCH 5/6] Add fromZoneFile to Profile which previously was as resolveToProfile in NameZoneFile --- src/profile/Profile.ts | 81 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/profile/Profile.ts b/src/profile/Profile.ts index 6e44cca..7e3ad39 100644 --- a/src/profile/Profile.ts +++ b/src/profile/Profile.ts @@ -1,6 +1,12 @@ +import { BlockstackCoreClient } from 'blockstack-core-client.ts'; + +import { config } from '../config'; import { extractProfile, signProfileToken } from './jwt'; import { IProof, validateProofs } from './proof'; import { ProfileJson } from './schema/Profile.json'; +import { NameZoneFile } from '../NameZoneFile'; +import { ProfileTokenJson } from './schema/ProfileToken.json'; +import { InvalidProfileTokenError } from '../error'; export class Profile implements ProfileJson { public static fromJSON(profileJson: ProfileJson): Profile { @@ -12,6 +18,81 @@ export class Profile implements ProfileJson { return Profile.fromJSON(profile); } + /** + * Creates a [[Profile]] from a given zone file, verified against the address looked up with the given [`BlockstackCoreClient`](https://github.com/ntzwrk/blockstack-core-client.ts) + * + * @param zoneFile The zone file to create the [[Profile]] from + * @param coreClient The [`BlockstackCoreClient`](https://github.com/ntzwrk/blockstack-core-client.ts) to use, defaults to the one set in ../config + * @returns A promise that resolves to the [[Profile]] on success and otherwise rejects with an error (see [[fetchProfileToken]] for these errors) + */ + public static async fromZoneFile(zoneFile: NameZoneFile, coreClient?: BlockstackCoreClient): Promise; + + /** + * Creates a [[Profile]] from a given zone file, verified against the provided public key or address + * + * @param zoneFile The zone file to create the [[Profile]] from + * @param publicKeyOrAddress The public key or address who owns this name / zone file + * @returns A promise that resolves to the [[Profile]] on success and otherwise rejects with an error (see [[fetchProfileToken]] for these errors) + */ + public static async fromZoneFile(zoneFile: NameZoneFile, publicKeyOrAddress: string): Promise; + + public static async fromZoneFile(zoneFile: NameZoneFile, parameter: BlockstackCoreClient | string): Promise { + let publicKeyOrAddress: string; + + if (typeof parameter === 'string') { + publicKeyOrAddress = parameter; + } else { + let coreClient: BlockstackCoreClient; + + if (parameter instanceof BlockstackCoreClient) { + coreClient = parameter; + } else { + coreClient = config.coreClient; + } + + const response = await coreClient.getNameInfo(this.name); + publicKeyOrAddress = response.address; + } + + const profileToken = await Profile.fetchProfileToken(zoneFile); + return Profile.fromToken(profileToken, publicKeyOrAddress); + } + + /** + * Fetches the profile token from a given zone file's profile token URL + * + * @param zoneFile The zone file to use + * @returns A promise that resolves to the profile token on success and otherwise rejects with: + * 1) an [[InvalidProfileTokenError]] when the profile token has no elements; + * 2) an [[InvalidProfileTokenError]] when the first element of the profile token has no `token` attribute. + * + * Please note that this function uses `fetch` and therefore can also reject with errors from there. + */ + private static async fetchProfileToken(zoneFile: NameZoneFile): Promise { + // TODO: This should be able to fallback on the next URI entry + + const response = await (await fetch(zoneFile.profileTokenUrl)).text(); + + let profileTokenJson: ProfileTokenJson; + try { + profileTokenJson = JSON.parse(response) as ProfileTokenJson; + } catch (error) { + throw new InvalidProfileTokenError(response); + } + + if (profileTokenJson.length === 0) { + throw new InvalidProfileTokenError(profileTokenJson, 'The profile token has no elements'); + } + if (profileTokenJson[0].token === undefined) { + throw new InvalidProfileTokenError( + profileTokenJson, + 'The first element of the profile token has no `token` attribute' + ); + } + + return profileTokenJson[0].token as string; + } + public readonly '@context': string = 'http://schema.org'; public readonly '@type': string; public readonly '@id': string; From e4caabf74680ee26f9b269c09fdab47d9b59d174 Mon Sep 17 00:00:00 2001 From: Valentin Sundermann Date: Sat, 23 Jun 2018 12:30:40 +0200 Subject: [PATCH 6/6] Update implementation for profile lookup, as it's now easier possible --- src/profile/lookup.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/profile/lookup.ts b/src/profile/lookup.ts index cfc5392..676c22d 100644 --- a/src/profile/lookup.ts +++ b/src/profile/lookup.ts @@ -1,5 +1,9 @@ -import { resolveZoneFileToProfile } from '../ProfileZoneFile'; -import { PersonJson } from './schema/Person.json'; +import { BlockstackCoreClient } from 'blockstack-core-client.ts'; + +import { config } from '../config'; +import { NameZoneFile } from '../NameZoneFile'; +import { Profile } from './Profile'; +import { ProfileJson } from './schema/Person.json'; /** * Look up a user profile by blockstack ID @@ -9,7 +13,7 @@ import { PersonJson } from './schema/Person.json'; * to use for zonefile lookup * @returns {Promise} that resolves to a profile object */ -export function lookupProfile( +/*export function lookupProfile( username: string, zoneFileLookupURL: string = 'https://core.blockstack.org/v1/names/' ): Promise { @@ -21,7 +25,7 @@ export function lookupProfile( .then(responseText => JSON.parse(responseText)) .then(responseJSON => { if (responseJSON.hasOwnProperty('zonefile') && responseJSON.hasOwnProperty('address')) { - resolve(resolveZoneFileToProfile(responseJSON.zonefile, responseJSON.address)); + resolve(NameZoneFile.fromString(responseJSON.zonefile).resolveToProfile(responseJSON.address)); } else { reject(); } @@ -33,4 +37,20 @@ export function lookupProfile( reject(e); } }); +}*/ + +export async function lookupProfile( + name: string, + zoneFileLookupURL: string = 'https://core.blockstack.org/v1/names/' +): Promise { + let coreClient: BlockstackCoreClient; + if (zoneFileLookupURL !== undefined) { + const url = new URL(zoneFileLookupURL); + coreClient = new BlockstackCoreClient(url.hostname, parseInt(url.port), url.protocol); + } else { + coreClient = config.coreClient; + } + + const zoneFile = await NameZoneFile.lookupByName(name, coreClient); + return Profile.fromZoneFile(zoneFile); }