Skip to content

Commit

Permalink
Merge pull request #19 from hull/release/0.11.0-beta.3
Browse files Browse the repository at this point in the history
Adds user -> account linking
  • Loading branch information
michaloo committed Apr 19, 2017
2 parents 29fad2d + 6175abb commit 1bf25df
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 41 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
19 changes: 14 additions & 5 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
};
}
};
Expand Down
36 changes: 28 additions & 8 deletions src/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,27 +40,47 @@ const VALID_PROPS = {
protocol: VALID.string,
userClaim: VALID.object,
accountClaim: VALID.object,
subjectType: VALID.string,
additionalClaims: VALID.object,
accessToken: VALID.string,
hostSecret: VALID.string,
flushAt: VALID.number,
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 };
}

Expand Down
4 changes: 2 additions & 2 deletions src/helpers/request-extract.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
38 changes: 22 additions & 16 deletions src/lib/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -85,6 +90,7 @@ module.exports = {
claims["io.hull.active"] = additionalClaims.active;
}

claims["io.hull.subjectType"] = subjectType;
return buildToken(config, claims);
},

Expand Down
91 changes: 82 additions & 9 deletions tests/client-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand All @@ -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);
});
});
});

0 comments on commit 1bf25df

Please sign in to comment.