diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae83dc..943670d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,16 @@ +# 0.11.0-beta.3 +* fix the `requestExtract` handler - allow passing `path` param +* fix the `.asUser()` and `.asAccount()` to return `traits` and `track` +* adds `.asUser().account()` method + # 0.11.0-beta.2 * Reorganize the utils/helpers * Introduce hull.as() create option * Upgrade raven API and add default exit handler * Combine notifHandler and batchHandler * Automatically filter out users using segment filter on user:update and NOT on batch actions +* Renames `hull().as()` method to `hull().asUser()` +* Adds initial support for accounts # 0.11.0-beta.1 diff --git a/package.json b/package.json index b9357dd..be2c257 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hull", - "version": "0.11.0-beta.2", + "version": "0.11.0-beta.3", "description": "A Node.js client for hull.io", "main": "lib", "repository": { diff --git a/src/client.js b/src/client.js index ccf088b..b7adb53 100644 --- a/src/client.js +++ b/src/client.js @@ -79,7 +79,7 @@ const Client = function Client(config = {}) { // Check conditions on when to create a "user client" or an "org client". // When to pass org scret or not - if (config.userId || config.accessToken) { + if (config.userClaim || config.accountClaim || config.accessToken) { this.traits = (traits, context = {}) => { // Quick and dirty way to add a source prefix to all traits we want in. const source = context.source; @@ -114,19 +114,28 @@ const Client = function Client(config = {}) { } }); }; + + if (config.userClaim) { + this.account = (accountClaim = {}) => { + if (!accountClaim) { + return new Client({ ...config, subjectType: "account" }); + } + return new Client({ ...config, subjectType: "account", accountClaim }); + }; + } } else { - this.asUser = (userClaim, additionalClaims) => { + this.asUser = (userClaim, additionalClaims = {}) => { if (!userClaim) { throw new Error("User Claims was not defined when calling hull.asUser()"); } - return new Client({ ...config, userClaim, additionalClaims }); + return new Client({ ...config, subjectType: "user", userClaim, additionalClaims }); }; - this.asAccount = (accountClaim, additionalClaims) => { + this.asAccount = (accountClaim, additionalClaims = {}) => { if (!accountClaim) { throw new Error("Account Claims was not defined when calling hull.asAccount()"); } - return new Client({ ...config, accountClaim, additionalClaims }); + return new Client({ ...config, subjectType: "account", accountClaim, additionalClaims }); }; } }; diff --git a/src/configuration.js b/src/configuration.js index d90a6be..20cf6ad 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -40,6 +40,7 @@ const VALID_PROPS = { protocol: VALID.string, userClaim: VALID.object, accountClaim: VALID.object, + subjectType: VALID.string, additionalClaims: VALID.object, accessToken: VALID.string, hostSecret: VALID.string, @@ -47,20 +48,39 @@ const VALID_PROPS = { flushAfter: VALID.number }; -class Configuration { +/** + * make sure that provided "identity claim" is valid + * @param {String} type "user" or "account" + * @param {String|Object} object identity claim + * @param {Array} requiredFields fields which are required if the passed + * claim is an object + * @throws Error + */ +function assertClaimValidity(type, object, requiredFields) { + if (!_.isEmpty(object)) { + if (_.isString(object)) { + if (!object) { + throw new Error(`Missing ${type} ID`); + } + } else if (!_.isObject(object) || _.intersection(_.keys(object), requiredFields).length === 0) { + throw new Error(`You need to pass an ${type} hash with an ${requiredFields.join(", ")} field`); + } + } +} +class Configuration { constructor(config) { if (!_.isObject(config) || !_.size(config)) { throw new Error("Configuration is invalid, it should be a non-empty object"); } - if (config.userClaim) { - const accessToken = crypto.lookupToken(config, "user", config.userClaim, config.additionalClaims); - config = { ...config, accessToken }; - } - - if (config.accountClaim) { - const accessToken = crypto.lookupToken(config, "account", config.accountClaim, config.additionalClaims); + if (config.userClaim || config.accountClaim) { + assertClaimValidity("user", config.userClaim, ["id", "email", "external_id", "anonymous_id"]); + assertClaimValidity("account", config.accountClaim, ["id", "external_id", "domain"]); + const accessToken = crypto.lookupToken(config, config.subjectType, { + user: config.userClaim, + account: config.accountClaim + }, config.additionalClaims); config = { ...config, accessToken }; } diff --git a/src/helpers/request-extract.js b/src/helpers/request-extract.js index 4d6fc9a..8463a0e 100644 --- a/src/helpers/request-extract.js +++ b/src/helpers/request-extract.js @@ -3,7 +3,7 @@ * @param {Object} options * @return {Promise} */ -export default function requestExtract(ctx, { segment = null, fields = [] } = {}) { +export default function requestExtract(ctx, { segment = null, path, fields = [] } = {}) { const { client, hostname } = ctx; - return client.utils.extract.request({ hostname, segment, fields }); + return client.utils.extract.request({ hostname, segment, path, fields }); } diff --git a/src/lib/crypto.js b/src/lib/crypto.js index 899b9e5..48ad20c 100644 --- a/src/lib/crypto.js +++ b/src/lib/crypto.js @@ -50,33 +50,38 @@ module.exports = { * and saves them as a custom ident claim. * * @param {Object} config object - * @param {String} type - "user" or "account" - * @param {String|Object} identClaim main idenditiy claim - object or string + * @param {String} subjectType - "user" or "account" + * @param {String|Object} userClaim main idenditiy claim - object or string + * @param {String|Object} accountClaim main idenditiy claim - object or string * @param {Object} additionalClaims * @returns {String} The jwt token to identity the user. */ - lookupToken(config, type, identClaim, additionalClaims = {}) { - type = _.toLower(type); - if (!_.includes(["user", "account"], type)) { + lookupToken(config, subjectType, objectClaims = {}, additionalClaims = {}) { + subjectType = _.toLower(subjectType); + if (!_.includes(["user", "account"], subjectType)) { throw new Error("Lookup token supports only `user` and `account` types"); } checkConfig(config); const claims = {}; - if (_.isString(identClaim)) { - if (!identClaim) { throw new Error(`Missing ${type} ID`); } - claims.sub = identClaim; - } else if (identClaim.id) { - claims.sub = identClaim.id; - } else { - if (type === "user" - && (!_.isObject(identClaim) || (!identClaim.email && !identClaim.external_id && !identClaim.anonymous_id))) { - throw new Error("You need to pass a user hash with an `email` or `external_id` or `anonymous_id` field"); - } - claims[`io.hull.as${_.upperFirst(type)}`] = identClaim; + const subjectClaim = objectClaims[subjectType]; + + if (_.isString(subjectClaim)) { + claims.sub = subjectClaim; + } else if (subjectClaim.id) { + claims.sub = subjectClaim.id; } + _.reduce(objectClaims, (c, oClaims, objectType) => { + if (_.isObject(oClaims) && !_.isEmpty(oClaims)) { + c[`io.hull.as${_.upperFirst(objectType)}`] = oClaims; + } else if (_.isString(oClaims) && !_.isEmpty(oClaims) && objectType !== subjectType) { + c[`io.hull.as${_.upperFirst(objectType)}`] = { id: oClaims }; + } + return c; + }, claims); + if (_.has(additionalClaims, "create")) { claims["io.hull.create"] = additionalClaims.create; } @@ -85,6 +90,7 @@ module.exports = { claims["io.hull.active"] = additionalClaims.active; } + claims["io.hull.subjectType"] = subjectType; return buildToken(config, claims); }, diff --git a/tests/client-tests.js b/tests/client-tests.js index f075a6b..bf05a8f 100644 --- a/tests/client-tests.js +++ b/tests/client-tests.js @@ -25,6 +25,23 @@ describe("Hull", () => { }); describe("as", () => { + it("should return scoped client with traits and track methods", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + const scopedAccount = hull.asAccount({ domain: "hull.io" }); + const scopedUser = hull.asUser("1234"); + + expect(scopedAccount).to.has.property("traits") + .that.is.an("function"); + expect(scopedAccount).to.has.property("track") + .that.is.an("function"); + + expect(scopedUser).to.has.property("traits") + .that.is.an("function"); + expect(scopedUser).to.has.property("track") + .that.is.an("function"); + }); + it("should allow to pass create option", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); @@ -50,26 +67,82 @@ describe("Hull", () => { .that.eql("123456"); }); - it("should allow to pass account id as an object property", () => { + it("should allow to pass account domain as an object property", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); - const scoped = hull.asAccount({ id: "123456" }); + const scoped = hull.asAccount({ domain: "hull.io" }); const scopedConfig = scoped.configuration(); const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); expect(scopedJwtClaims) - .to.have.property("sub") - .that.eql("123456"); + .to.have.property("io.hull.asAccount") + .that.eql({ domain: "hull.io" }); + expect(scopedJwtClaims) + .to.have.property("io.hull.subjectType") + .that.eql("account"); }); - it("should allow to pass account name as an object property", () => { + it("should allow to link user to an account", () => { const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); - const scoped = hull.asAccount({ name: "Hull" }); - const scopedConfig = scoped.configuration(); - const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret); + const scoped = hull.asUser({ email: "foo@bar.com" }).account({ domain: "hull.io" }); + const scopedJwtClaims = jwt.decode(scoped.configuration().accessToken, scoped.configuration().secret); + + expect(scopedJwtClaims) + .to.have.property("io.hull.subjectType") + .that.eql("account"); + expect(scopedJwtClaims) + .to.have.property("io.hull.asAccount") + .that.eql({ domain: "hull.io" }); + expect(scopedJwtClaims) + .to.have.property("io.hull.asUser") + .that.eql({ email: "foo@bar.com" }); + }); + + it("should allow to link a user using its id to an account", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + const scoped = hull.asUser("1234").account({ domain: "hull.io" }); + const scopedJwtClaims = jwt.decode(scoped.configuration().accessToken, scoped.configuration().secret); + + expect(scopedJwtClaims) + .to.have.property("io.hull.subjectType") + .that.eql("account"); expect(scopedJwtClaims) .to.have.property("io.hull.asAccount") - .that.eql({ name: "Hull" }); + .that.eql({ domain: "hull.io" }); + expect(scopedJwtClaims) + .to.have.property("io.hull.asUser") + .that.eql({ id: "1234" }); + }); + + it("should allow to resolve an existing account user is linked to", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + const scoped = hull.asUser({ email: "foo@bar.com" }).account(); + const scopedJwtClaims = jwt.decode(scoped.configuration().accessToken, scoped.configuration().secret); + + expect(scopedJwtClaims) + .to.have.property("io.hull.subjectType") + .that.eql("account"); + expect(scopedJwtClaims) + .to.not.have.property("io.hull.asAccount"); + expect(scopedJwtClaims) + .to.have.property("io.hull.asUser") + .that.eql({ email: "foo@bar.com" }); + }); + + it("should throw an error if any of required field is not passed", () => { + const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" }); + + expect(hull.asUser.bind(null, { some_id: "1234" })) + .to.throw(Error); + expect(hull.asAccount.bind(null, { some_other_id: "1234" })) + .to.throw(Error); + + expect(hull.asUser.bind(null, { external_id: "1234" })) + .to.not.throw(Error); + expect(hull.asAccount.bind(null, { external_id: "1234" })) + .to.not.throw(Error); }); }); });