From 5a851154d3cbdf119c7ff1811c127ac7a101e0e2 Mon Sep 17 00:00:00 2001 From: Michael Dyer Date: Thu, 13 Aug 2020 10:00:49 -0700 Subject: [PATCH] User service: login, registration, graphql completion (#3) * user schema, abstract dynamo db class, user integration test * completed integration test suite * delete test cleanup * user GQL service integration, start spec for user schema, service connection to gql middleware * - dynamic config/environment support - ddb abstract support of environments - sls pseudo parameters for env support - debugging cleanup - tsconfig es2018 update (prep for type-graphql) * mocha cli testing, actions * testing workflow * testing workflow * adding package-lock * workflow testing step * join build/test jobs * re-naming workflow * user route/svc, login/mfa/oob methods, correct error handling, a0 envvars through ssm * user service static method tests, http wrapper tests, updated env support for user svc * rolling back router response type change * user registration consolidation to graph endpoint, code cleanup, strict type checking on graph endpoint * update content-type for graph requests to json * - updated test cases to account for user svc updates - user svc field requirement testing testing (email, sub, phone) - user svc email field regex - user svc sub format to follow A0 (database|uuid) - user svc phone number to support U.S. prefix/format only - user schema write now requires (email, phone, sub) - user schema marshaller createdAt correction - user route query payload regex correction * user svc validation refactor, PR feedback interation * email field regex update, fix for missing phone field in payload, updated test cases * no unused var fix * - completed user registration flow - refactored user authentication flow - user mfa verification flow, post registration --- __tests__/authorizers/auth0.spec.ts | 2 +- __tests__/helpers/axios.spec.ts | 39 ------ __tests__/services/http.spec.ts | 46 +++++++ __tests__/services/user.integration.ts | 97 +++++++++++--- __tests__/services/user.spec.ts | 113 ++++++++++++++++ __tests__/streams/user.spec.ts | 80 ------------ handlers/bi.ts | 3 + handlers/graphql.ts | 3 - handlers/user.ts | 7 +- package-lock.json | 50 +++++++- package.json | 4 +- serverless.yml | 116 ++++++++++++++--- src/authorizers/auth0.ts | 2 +- src/entities/user/interfaces.ts | 15 ++- src/entities/user/resolvers.ts | 17 ++- src/entities/user/schema.ts | 5 +- src/entities/user/table.ts | 12 +- src/helpers/axios/http.ts | 33 ++++- src/helpers/axios/interceptor.ts | 4 +- src/helpers/body.ts | 5 + src/helpers/config.ts | 10 +- src/helpers/headers.ts | 7 + src/helpers/interfaces/all.ts | 9 ++ src/helpers/interfaces/auth0.ts | 4 + src/helpers/response.ts | 18 +++ src/i18n/authorizer.ts | 2 +- src/i18n/registration.ts | 5 + src/i18n/routes.ts | 3 + src/i18n/user.ts | 4 + src/routes/0.0.1/user/index.ts | 83 +++++++++--- src/services/auth0.ts | 4 +- src/services/http.ts | 18 +++ src/services/user.ts | 171 +++++++++++++++++++++++-- src/streams/users.ts | 29 ++++- src/tasks/bi.ts | 4 + tsconfig.json | 3 +- wallaby.js | 1 + webpack.config.js | 3 +- 38 files changed, 810 insertions(+), 221 deletions(-) delete mode 100644 __tests__/helpers/axios.spec.ts create mode 100644 __tests__/services/http.spec.ts create mode 100644 __tests__/services/user.spec.ts delete mode 100644 __tests__/streams/user.spec.ts create mode 100644 handlers/bi.ts delete mode 100644 handlers/graphql.ts create mode 100644 src/helpers/body.ts create mode 100644 src/helpers/headers.ts create mode 100644 src/helpers/response.ts create mode 100644 src/i18n/registration.ts create mode 100644 src/i18n/routes.ts create mode 100644 src/i18n/user.ts create mode 100644 src/services/http.ts create mode 100644 src/tasks/bi.ts diff --git a/__tests__/authorizers/auth0.spec.ts b/__tests__/authorizers/auth0.spec.ts index 096686c..9dd4596 100644 --- a/__tests__/authorizers/auth0.spec.ts +++ b/__tests__/authorizers/auth0.spec.ts @@ -9,7 +9,7 @@ import * as jwt from 'jsonwebtoken'; import { A0 } from '@services/auth0'; import { authenticateToken, decodeToken, generatePolicy, getSigningKey, stripTokenFromHeader } from '@authorizers/auth0'; -import { Responses as lang } from '@i18n/authorizer'; +import { Authorizer as lang } from '@i18n/authorizer'; describe('auth0-authorizer', () => { const sandbox = sinon.createSandbox(); diff --git a/__tests__/helpers/axios.spec.ts b/__tests__/helpers/axios.spec.ts deleted file mode 100644 index 4c7d1b8..0000000 --- a/__tests__/helpers/axios.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ - -const sinon = require('sinon'); - -import { AxiosResponse } from 'axios'; -import { expect } from 'chai'; -import { HttpClient } from '@helpers/axios/http'; - -describe('axios', () => { - const sandbox = sinon.createSandbox(); - - class Klass extends HttpClient { - constructor(domain: string) { - super(`https://${domain}`); - } - - public get = (): Promise> => this.instance.get('/'); - } - - afterEach(function () { - sandbox.restore(); - }); - - describe('http', () => { - it('should return a promise', async () => { - const foo = new Klass('foo'); - sandbox.stub(foo, 'get').resolves(true); - let test; - - try { - test = await foo.get(); - } catch (error) { - console.error(error); - } finally { - expect(test).to.be.true; - } - }); - }); -}) \ No newline at end of file diff --git a/__tests__/services/http.spec.ts b/__tests__/services/http.spec.ts new file mode 100644 index 0000000..d136153 --- /dev/null +++ b/__tests__/services/http.spec.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +const sinon = require('sinon'); + +import { expect } from 'chai'; + +import { httpWrapper as https } from '@services/http'; + +describe('http', () => { + const sandbox = sinon.createSandbox(); + + afterEach(function () { + sandbox.restore(); + }); + + describe('get', () => { + it('promise should resolve', async () => { + let request; + const klass = new https('foo.bar'); + sandbox.stub(klass, 'get').resolves(true); + + try { + request = await klass.get('/baz'); + console.log(request); + } catch (error) { + console.log(error); + } finally { + expect(request).to.equal(true); + } + }); + + it('promise should reject', async () => { + let request; + const klass = new https('foo.bar'); + sandbox.stub(klass, 'get').rejects(false); + + try { + request = await klass.get('/baz'); + console.log(request); + } catch (error) { + console.log(error); + expect(request).to.have.throw; + } + }); + }); +}) \ No newline at end of file diff --git a/__tests__/services/user.integration.ts b/__tests__/services/user.integration.ts index 306d473..2522a38 100644 --- a/__tests__/services/user.integration.ts +++ b/__tests__/services/user.integration.ts @@ -6,8 +6,7 @@ import { expect } from 'chai'; import { User } from '@services/user'; describe.skip('user', () => { - let klass; - let payload; + let klass, payload; const sandbox = sinon.createSandbox(); @@ -16,7 +15,7 @@ describe.skip('user', () => { }); beforeEach(function(done) { - payload = { email: 'foo@bar.baz', sub: +new Date() }; + payload = { email: 'foo@bar.baz', phone: '+19995551111' }; klass = new User(); const tableExists = klass.ensureTableExists(); @@ -29,9 +28,9 @@ describe.skip('user', () => { describe('delete', () => { it('should delete record', (done) => { - const payload = { email: 'faz@bar.boo', sub: +new Date() }; + const payload = { email: 'faz@bar.boo', phone: '+19995551111' }; const put = klass.execute('put', payload); - const del = klass.execute('delete', payload) + const del = klass.execute('delete', { email: payload.email }); Promise.all([put, del]).then(results => { expect(results[results.length - 1].email).to.equal(payload.email); @@ -40,12 +39,10 @@ describe.skip('user', () => { }); it('should return error', async () => { - const payload = { foo: 'bar@foo.baz', sub: +new Date() }; - try { - await klass.execute('delete', payload); + await klass.execute('delete', { foo: 'bar@baz' }); } catch (error) { - expect(error.code).to.equal('ValidationException'); + expect(error.message).to.equal('Field does not match requirements: [email, undefined]'); } }); }); @@ -67,7 +64,7 @@ describe.skip('user', () => { }); describe('get', () => { - it('should return record', async () => { + it('should return record by full payload', async () => { let record; try { @@ -80,13 +77,27 @@ describe.skip('user', () => { expect(record.email).to.equal(payload.email); }); - it('should return error', async () => { + it('should return record by email', async () => { + let record; + const p = { email: 'foo@bar.baz' }; + + try { + record = await klass.execute('get', p); + } catch (error) { + console.log(error); + return; + } + + expect(record.email).to.equal(payload.email); + }); + + it('should return error due to no valid parameters', async () => { const payload = { foo: 'bar' }; try { await klass.execute('get', payload); } catch (error) { - expect(error.code).to.equal('ValidationException'); + expect(error.message).to.equal('Field does not match requirements: [email, undefined]'); } }); }); @@ -94,27 +105,53 @@ describe.skip('user', () => { describe('put', () => { it('should write record', async () => { let record; - const email = 'baz@bar.foo'; - const sub = +new Date(); + const email = 'foo@bar.baz'; + const phone = '+19995551111'; try { - record = await klass.execute('put', { email: email, sub: sub }); + record = await klass.execute('put', { email, phone }); } catch (error) { - console.log(error); - return; + console.error(error); } expect(record.email).to.equal(email); }); + + it('should violate email format requirement', async () => { + let record; + const email = 'baz@bar'; + const phone = '+19995551111'; + + try { + record = await klass.execute('put', { email, phone }); + } catch (error) { + expect(error.message).to.equal(`Field does not match requirements: [email, ${email}]`); + } + + console.log(record); + }); + + it('should violate phone format requirement', async () => { + const email = 'baz@bar.foo'; + const phone = '+828282'; + + try { + await klass.execute('put', { email, phone }); + } catch (error) { + console.log(error); + expect(error.message).to.equal(`Field does not match requirements: [phone, ${phone}]`); + } + }); }); describe('update', () => { it('should update record', async () => { let record; - const sub = +new Date(); + const phone = '+10005551111'; + const sub = 'test|0987654321'; try { - record = await klass.execute('update', { email: payload.email, sub: sub }); + record = await klass.execute('update', { email: payload.email, phone, sub }); } catch (error) { console.log(error); return; @@ -122,5 +159,27 @@ describe.skip('user', () => { expect(record.sub).to.equal(sub.toString()); }); + + it('should violate phone requirement', async () => { + const sub = 'test|1234567890'; + + try { + await klass.execute('update', { email: payload.email, sub }); + } catch (error) { + console.log(error); + expect(error.message).to.equal(`Field does not match requirements: [phone, undefined]`); + } + }); + + it('should violate sub requirement', async () => { + const phone = '+10005551111'; + + try { + await klass.execute('update', { email: payload.email, phone }); + } catch (error) { + console.log(error); + expect(error.message).to.equal(`Field does not match requirements: [sub, undefined]`); + } + }); }); }); \ No newline at end of file diff --git a/__tests__/services/user.spec.ts b/__tests__/services/user.spec.ts new file mode 100644 index 0000000..1aebbe7 --- /dev/null +++ b/__tests__/services/user.spec.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const sinon = require('sinon'); + +import { expect } from 'chai'; + +import { httpWrapper as Http } from '@services/http'; +import { User as user } from '@services/user'; + +describe('user', () => { + const sandbox = sinon.createSandbox(); + + afterEach(function () { + sandbox.restore(); + }); + + describe('auth', () => { + it('promise should resolve', async () => { + let response; + const payload = { mfa_token: 'foo' }; + + sandbox.stub(Http.prototype, 'post').resolves(payload); + + try { + response = await user.auth('foo@bar.baz', 'qux'); + } catch (error) { + console.log(error); + } finally { + expect(response).to.equal(payload.mfa_token); + } + }); + + it('promise should reject, but forward mfa_challenge', async () => { + let response; + const exception = { + error: 'mfa_required', + mfa_token: 'foo' + } + + sandbox.stub(Http.prototype, 'post').rejects(exception); + + try { + response = await user.auth('foo@bar.baz', 'qux'); + } catch (error) { + console.log(error); + } finally { + expect(response).to.equal(exception.mfa_token); + } + }); + + it('promise should reject', async () => { + const exception = { error: 'foo' }; + + sandbox.stub(Http.prototype, 'post').rejects(exception); + + try { + await user.auth('foo@bar.baz', 'qux'); + } catch (error) { + expect(error).to.deep.equal(exception); + } + }); + }); + + describe('oauth', () => { + it('promise should resolve', async () => { + let request; + sandbox.stub(Http.prototype, 'post').resolves(true); + + try { + request = await user.oauth('foo', 'bar', 'baz'); + } catch (error) { + console.log(error); + } finally { + expect(request).to.be.true; + } + }); + + it('promise should reject', async () => { + sandbox.stub(Http.prototype, 'post').rejects(false); + + try { + await user.oauth('foo', 'bar', 'baz'); + } catch (error) { + expect(error).to.be.throw; + } + }); + }); + + describe('oob', () => { + it('promise should resolve', async () => { + let request; + sandbox.stub(Http.prototype, 'post').resolves({ oob_code: 'foo' }); + + try { + request = await user.oob('bar'); + } catch (error) { + console.log(error); + } finally { + expect(request).to.equal('foo'); + } + }); + + it('promise should reject', async () => { + let request; + sandbox.stub(Http.prototype, 'post').rejects(false); + + try { + request = await user.oob('bar'); + } catch (error) { + expect(request).to.be.throw; + } + }); + }); +}); \ No newline at end of file diff --git a/__tests__/streams/user.spec.ts b/__tests__/streams/user.spec.ts deleted file mode 100644 index 53059fd..0000000 --- a/__tests__/streams/user.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const sinon = require('sinon'); - -import * as AWS from 'aws-sdk'; -import { expect } from 'chai'; - -import { stream } from '@streams/users'; - -describe('user stream', () => { - const sandbox = sinon.createSandbox(); - - afterEach(function () { - sandbox.restore(); - }); - - describe('fn', () => { - it('should resolve true', async () => { - const timestamp = +new Date(); - const event = { - Records: [{ - dynamodb: { - NewImage: { - sub: { S: timestamp }, - email: { S: 'foo@bar.baz' } - } - } - }] - }; - const context = { - functionName: 'foo', - functionVersion: 'bar' - } - - const ctx = await stream(event, context); - expect(ctx).to.deep.equal({ sub: { S: timestamp }, email: { S: 'foo@bar.baz' } }); - }); - xit('should recursively call fn with second child of payload', async () => { - // TODO: confused on how to stub inner AWS SDK call - const context = { functionName: 'foo', functionVersion: 'bar' } - const event = { - Records: [{ - dynamodb: { - NewImage: { - sub: { S: '1' }, - email: { S: 'foo@bar.baz' } - } - } - }, { - dynamodb: { - NewImage: { - sub: { S: '2' }, - email: { S: 'baz@bar.foo' } - } - } - }] - }; - - const lambda = new AWS.Lambda(); - sinon.stub(lambda, 'invoke').withArgs({ - FunctionName: context.functionName, - InvocationType: 'Event', - Payload: JSON.stringify(event), - Qualifier: context.functionVersion - }).returns({ - promise: () => ({ - AcceptRanges: 'bytes', - LastModified: new Date('2018-04-25T13:32:58.000Z'), - ContentLength: 23, - ETag: '"ae771fbbba6a74eeeb77754355831713"', - ContentType: 'text/plain', - Metadata: {}, - Body: Buffer.from('Test file\n') - }) - }); - - const ctx = await stream(event, context); - expect(ctx).to.deep.equal({ sub: { S: '1' }, email: { S: 'foo@bar.baz' } }); - }); - }); -}); \ No newline at end of file diff --git a/handlers/bi.ts b/handlers/bi.ts new file mode 100644 index 0000000..9acefdd --- /dev/null +++ b/handlers/bi.ts @@ -0,0 +1,3 @@ +import { beacon } from '@tasks/bi'; + +export const mixpanel = beacon; \ No newline at end of file diff --git a/handlers/graphql.ts b/handlers/graphql.ts deleted file mode 100644 index 1423da2..0000000 --- a/handlers/graphql.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { routes } from '@routes/0.0.1/user'; - -export const user = routes.graph; \ No newline at end of file diff --git a/handlers/user.ts b/handlers/user.ts index 134c0aa..5b79bbd 100644 --- a/handlers/user.ts +++ b/handlers/user.ts @@ -1,3 +1,6 @@ -import { routes } from '@routes/0.0.1/user'; +import { associate, graph, login, register } from '@routes/0.0.1/user'; -export const register = routes.register; \ No newline at end of file +export const mfa = associate; +export const query = graph; +export const session = login; +export const signup = register; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 996d191..772794e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,13 @@ "reflect-metadata": "^0.1.10", "tslib": "^1.8.1", "uuid": "^3.0.0" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "@aws/dynamodb-data-marshaller": { @@ -1709,6 +1716,12 @@ "has-flag": "^3.0.0" } }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + }, "write-file-atomic": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", @@ -3223,6 +3236,14 @@ "url": "0.10.3", "uuid": "3.3.2", "xml2js": "0.4.19" + }, + "dependencies": { + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", + "dev": true + } } }, "aws-sign2": { @@ -7126,6 +7147,13 @@ "deprecated-decorator": "^0.1.6", "iterall": "^1.1.3", "uuid": "^3.1.0" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "graphql-upload": { @@ -11459,6 +11487,13 @@ "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } } }, "request-promise-core": { @@ -12453,6 +12488,15 @@ } } }, + "serverless-iam-roles-per-function": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/serverless-iam-roles-per-function/-/serverless-iam-roles-per-function-2.0.2.tgz", + "integrity": "sha512-3zv4Af3x7KJHC4fuJMHniEEstCljMghReaYIg94Spdsqkhiy8GJ3yfJ5Jy+wiG/DvyObK91JJNMjLhDnDc+vCQ==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, "serverless-offline": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/serverless-offline/-/serverless-offline-6.4.0.tgz", @@ -14482,9 +14526,9 @@ } }, "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==" }, "v8-compile-cache": { "version": "2.1.1", diff --git a/package.json b/package.json index dc05595..0800858 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "jwks-rsa": "^1.8.1", "npm-check-updates": "^7.0.1", "request": "^2.88.2", - "typescript": "^3.9.5" + "typescript": "^3.9.5", + "uuid": "^8.3.0" }, "devDependencies": { "aws-sdk": "^2.709.0", @@ -34,6 +35,7 @@ "mocha": "^8.0.1", "mochapack": "^2.0.3", "serverless": "^1.74.1", + "serverless-iam-roles-per-function": "^2.0.2", "serverless-offline": "^6.4.0", "serverless-plugin-warmup": "^4.9.0", "serverless-pseudo-parameters": "^2.5.0", diff --git a/serverless.yml b/serverless.yml index 25a7c1e..a3dcd8b 100644 --- a/serverless.yml +++ b/serverless.yml @@ -11,7 +11,12 @@ provider: ENVIRONMENT: ${self:provider.stage} REGION: ${self:provider.region} logs: - restApi: true + restApi: + fullExecutionData: false + level: ERROR + websocket: + level: ERROR + fullExecutionData: false tags: provider: serverless tracing: @@ -22,15 +27,15 @@ provider: - ${ssm:/development/vpc/sg/apigw/id} subnetIds: ${ssm:/development/vpc/subnet/private/arns~split} iamRoleStatements: - - Effect: "Allow" + - Effect: Allow Action: - "dynamodb:*" Resource: - "arn:aws:dynamodb:#{AWS::Region}:#{AWS::AccountId}:table/*" - - Effect: "Allow" + - Effect: Allow Action: - - "xray:PutTraceSegments" - - "xray:PutTelemetryRecords" + - xray:PutTraceSegments + - xray:PutTelemetryRecords Resource: - "*" @@ -42,7 +47,6 @@ custom: functions: auth0: handler: handlers/authorizer.authenticate - resultTtlInSeconds: 0 environment: A0_ALGORITHM: RS256 A0_DOMAIN: mhp.us.auth0.com @@ -63,17 +67,58 @@ functions: connectionType: vpc-link connectionId: ${ssm:/development/vpc/apigw/link/id~true} cors: true - register: - handler: handlers/user.register + login: + handler: handlers/user.session + environment: + A0_DOMAIN: mhp.us.auth0.com + A0_CLIENT_ID: ${ssm:/development/vendor/auth0/client/id~true} + A0_CLIENT_SECRET: ${ssm:/development/vendor/auth0/client/secret~true} events: - http: - path: /register - method: get + path: /login + method: post connectionType: vpc-link connectionId: ${ssm:/development/vpc/apigw/link/id~true} cors: true + mfa: + handler: handlers/user.mfa + resultTtlInSeconds: 0 + environment: + A0_DOMAIN: mhp.us.auth0.com + A0_CLIENT_ID: ${ssm:/development/vendor/auth0/client/id~true} + A0_CLIENT_SECRET: ${ssm:/development/vendor/auth0/client/secret~true} + events: + - http: + path: /mfa + method: post + cors: true + register: + handler: handlers/user.signup + resultTtlInSeconds: 0 + environment: + A0_DOMAIN: mhp.us.auth0.com + A0_CLIENT_ID: ${ssm:/development/vendor/auth0/client/id~true} + A0_CLIENT_SECRET: ${ssm:/development/vendor/auth0/client/secret~true} + events: + - http: + path: /register + method: post + cors: true streamsUser: handler: handlers/streams.user + environment: + REGISTRATION_ARN: "arn:aws:states:#{AWS::Region}:#{AWS::AccountId}:stateMachine:${self:service}-${self:provider.stage}-registration" + iamRoleStatements: + - Effect: Allow + Action: + - states:DescribeStateMachine + - states:ListActivities + - states:ListExecutions + - states:ListStateMachines + - states:StartExecution + - states:UpdateStateMachine + Resource: + - "arn:aws:states:#{AWS::Region}:#{AWS::AccountId}:stateMachine:*" events: - stream: arn: ${ssm:/development/ddb/stream/arn} @@ -84,29 +129,58 @@ functions: destinations: onFailure: ${ssm:/development/sns/dlq/stream/arn} user: - handler: handlers/graphql.user + handler: handlers/user.query + resultTtlInSeconds: 0 + environment: + A0_DOMAIN: mhp.us.auth0.com + A0_CLIENT_ID: ${ssm:/development/vendor/auth0/client/id~true} + A0_CLIENT_SECRET: ${ssm:/development/vendor/auth0/client/secret~true} events: - http: - path: /graphql - method: get - authorizer: auth0 - connectionType: vpc-link - connectionId: ${ssm:/development/vpc/apigw/link/id~true} - cors: true - - http: - path: /graphql + path: /user method: post authorizer: auth0 connectionType: vpc-link connectionId: ${ssm:/development/vpc/apigw/link/id~true} cors: true + mixpanel: + handler: handlers/bi.mixpanel + resultTtlInSeconds: 0 + signup: + handler: handlers/bi.mixpanel + resultTtlInSeconds: 0 + +stepFunctions: + stateMachines: + registration: + name: "${self:service}-${self:provider.stage}-registration" + definition: + StartAt: beacon + States: + beacon: + Type: Task + Resource: "arn:aws:lambda:#{AWS::Region}:#{AWS::AccountId}:function:${self:service}-${opt:stage}-mixpanel" + End: true + +resources: + Resources: + GatewayResponseDefault4XX: + Type: 'AWS::ApiGateway::GatewayResponse' + Properties: + ResponseParameters: + gatewayresponse.header.Access-Control-Allow-Origin: "'*'" + gatewayresponse.header.Access-Control-Allow-Headers: "'*'" + ResponseType: DEFAULT_4XX + RestApiId: + Ref: 'ApiGatewayRestApi' package: individually: true plugins: - serverless-webpack - - serverless-offline + - serverless-iam-roles-per-function - serverless-step-functions - serverless-pseudo-parameters - # - serverless-plugin-warmup + - serverless-offline + # - serverless-plugin-warmup \ No newline at end of file diff --git a/src/authorizers/auth0.ts b/src/authorizers/auth0.ts index 8973d44..0fa91c3 100644 --- a/src/authorizers/auth0.ts +++ b/src/authorizers/auth0.ts @@ -5,7 +5,7 @@ import { Context } from 'aws-lambda'; import * as jwt from 'jsonwebtoken'; import * as jwksClient from 'jwks-rsa'; -import { Responses as lang } from '@i18n/authorizer'; +import { Authorizer as lang } from '@i18n/authorizer'; import { Authorizer, Callback } from '@helpers/interfaces/all'; export const authorize = (event: any, _context: Context, callback: Callback): void => { diff --git a/src/entities/user/interfaces.ts b/src/entities/user/interfaces.ts index f907627..7f44b66 100644 --- a/src/entities/user/interfaces.ts +++ b/src/entities/user/interfaces.ts @@ -1,5 +1,18 @@ +export interface a0 { + access_token: string, + id_token: string, + scope: string, + expires_in: string | number, + token_type: string +} + +export interface requirements { + [key: string]: string[] +} + export interface user { createdAt?: Date, email: string, - sub: string, + phone: string, + sub: string } \ No newline at end of file diff --git a/src/entities/user/resolvers.ts b/src/entities/user/resolvers.ts index 961b369..a1b8e73 100644 --- a/src/entities/user/resolvers.ts +++ b/src/entities/user/resolvers.ts @@ -1,15 +1,26 @@ import { User } from '@services/user'; +let data; const service = new User(); export const resolvers = { - user: async (payload: unknown): Promise => { - let data; + register: async (payload: { [key: string]: string }): Promise => { + try { + const user = await service.register(payload); + data = await service.execute('put', { ...payload, sub: `auth0|${user['_id']}`}); + } catch (error) { + const msg = error.message || `${error.name}[${error.code}]: ${error.description}`; + console.error(error); + return new Error(msg); + } + return data; + }, + user: async (payload: unknown): Promise => { try { data = await service.execute('get', payload); } catch (error) { - console.log(error); + console.error(error); return new Error(error); } diff --git a/src/entities/user/schema.ts b/src/entities/user/schema.ts index 3fa562c..4afd1ca 100644 --- a/src/entities/user/schema.ts +++ b/src/entities/user/schema.ts @@ -5,7 +5,10 @@ export const schema: typeof GraphQLSchema = buildSchema(` type User { email: String! sub: String - createdAt: String + } + + type Mutation { + register(email: String!, name: String, password: String!): User } type Query { diff --git a/src/entities/user/table.ts b/src/entities/user/table.ts index c1c4acf..510c975 100644 --- a/src/entities/user/table.ts +++ b/src/entities/user/table.ts @@ -1,15 +1,15 @@ import { attribute, hashKey, table } from '@aws/dynamodb-data-mapper-annotations'; -const ISOdate = (d = new Date()) => d.toISOString(); - @table('users') export class Schema { @hashKey() - email: string; + email!: string; @attribute() - sub: string; + sub!: string; - @attribute({ defaultProvider: () => ISOdate }) - createdAt: string; + // NOTE: PII, non-persisted + name?: string; + password!: string; + phone!: string; } \ No newline at end of file diff --git a/src/helpers/axios/http.ts b/src/helpers/axios/http.ts index f00e7ca..cdd1ce3 100644 --- a/src/helpers/axios/http.ts +++ b/src/helpers/axios/http.ts @@ -1,11 +1,40 @@ -import axios, { AxiosInstance } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { Error } from '@helpers/interfaces/auth0'; export abstract class HttpClient { + private readonly contentType: string; protected readonly instance: AxiosInstance; - constructor(baseURL: string) { + constructor(baseURL: string, contentType = 'application/json') { this.instance = axios.create({ baseURL, }); + + this.contentType = contentType; + this._initializeRequestInterceptor(); + this._initializeResponseInterceptor(); } + + private _initializeRequestInterceptor = () => { + this.instance.interceptors.request.use( + this._handleRequest, + this._handleError, + ); + }; + + private _initializeResponseInterceptor = () => { + this.instance.interceptors.response.use( + this._handleResponse, + this._handleError, + ); + }; + + protected _handleError = (error: Error): Promise => Promise.reject(error.response.data); + private _handleResponse = ({ data }: AxiosResponse) => data; + private _handleRequest = (config: AxiosRequestConfig) => { + config.headers.get['Content-Type'] = this.contentType; + config.headers.post['Content-Type'] = this.contentType; + + return config; + }; } \ No newline at end of file diff --git a/src/helpers/axios/interceptor.ts b/src/helpers/axios/interceptor.ts index fb59500..36b9e1b 100644 --- a/src/helpers/axios/interceptor.ts +++ b/src/helpers/axios/interceptor.ts @@ -3,6 +3,8 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { Error } from '@helpers/interfaces/auth0'; + declare module 'axios' { interface AxiosResponse extends Promise { } } @@ -39,7 +41,7 @@ export abstract class HttpClient { ); }; - protected _handleError = (error: any) => Promise.reject(error.response.data); + protected _handleError = (error: Error): Promise => Promise.reject(error.response.data); private _handleResponse = ({ data }: AxiosResponse) => data; private _handleRequest = (config: AxiosRequestConfig) => { config.headers['Authorization'] = `Bearer ${this.bearerToken}`; diff --git a/src/helpers/body.ts b/src/helpers/body.ts new file mode 100644 index 0000000..7a5f924 --- /dev/null +++ b/src/helpers/body.ts @@ -0,0 +1,5 @@ +export const parse = (payload: string): string => { + // NOTE: lambda proxy can pass event.body with UTF-8 encoding (\n, \\) + const { query } = JSON.parse(payload); + return query.replace(/(\\n?)/g, '').replace(/[\s]{2,}/g, ' '); +} \ No newline at end of file diff --git a/src/helpers/config.ts b/src/helpers/config.ts index 367026e..a429b22 100644 --- a/src/helpers/config.ts +++ b/src/helpers/config.ts @@ -1,7 +1,15 @@ const env: string = process.env.ENVIRONMENT || 'local'; const local: boolean = env === 'local'; +export const auth0 = { + id: local ? 'foo' : process.env.A0_CLIENT_ID, + domain: local ? 'foo.bar.baz' : process.env.A0_DOMAIN, + secret: local ? 'foo' : process.env.A0_CLIENT_SECRET +} + export const dynamodb = { endpoint: local ? 'http://localhost:4566' : `dynamodb.${process.env.REGION}.amazonaws.com`, region: local ? 'us-east-1' : process.env.REGION -} \ No newline at end of file +} + +export const region = local ? 'us-west-2' : process.env.REGION \ No newline at end of file diff --git a/src/helpers/headers.ts b/src/helpers/headers.ts new file mode 100644 index 0000000..3a6e2c4 --- /dev/null +++ b/src/helpers/headers.ts @@ -0,0 +1,7 @@ +import { Routes as _routes } from '@i18n/routes'; + +export const contentTypeCheck = (header: string, type: string): void => { + if (header !== type) { + throw new Error(_routes.CONTENT_TYPE.replace('__', type)); + } +} \ No newline at end of file diff --git a/src/helpers/interfaces/all.ts b/src/helpers/interfaces/all.ts index 3cb8cbc..d93a848 100644 --- a/src/helpers/interfaces/all.ts +++ b/src/helpers/interfaces/all.ts @@ -15,6 +15,15 @@ export interface Authorizer { export interface Callback { (err: any|null, handler?: any): void } +export interface Generic { + [key: string]: unknown +} + +export interface Response { + statusCode: number, + body: string +} + export interface Route { [key: string]: APIGatewayProxyHandler } \ No newline at end of file diff --git a/src/helpers/interfaces/auth0.ts b/src/helpers/interfaces/auth0.ts index 736017f..3a9d190 100644 --- a/src/helpers/interfaces/auth0.ts +++ b/src/helpers/interfaces/auth0.ts @@ -1,3 +1,7 @@ +export interface Error { + [key: string]: { [key: string]: string } +} + export interface UserInfo { sub: string, nickname: string, diff --git a/src/helpers/response.ts b/src/helpers/response.ts new file mode 100644 index 0000000..91e4cf9 --- /dev/null +++ b/src/helpers/response.ts @@ -0,0 +1,18 @@ +import { Response } from '@helpers/interfaces/all'; + +function replaceErrors(_key, value, error = {}) { + if (value instanceof Error) { + Object.getOwnPropertyNames(value).forEach(function (key) { + error[key] = value[key]; + }); + + return error; + } + + return value; +} + +export const response = (code: number, body: unknown, error = false): Response => ({ + statusCode: code, + body: JSON.stringify(body, error ? replaceErrors : null, 2) +}); \ No newline at end of file diff --git a/src/i18n/authorizer.ts b/src/i18n/authorizer.ts index 66c6739..8e429dc 100644 --- a/src/i18n/authorizer.ts +++ b/src/i18n/authorizer.ts @@ -1,4 +1,4 @@ -export enum Responses { +export enum Authorizer { ERROR = 'Error: Invalid token', UNAUTHORIZED = 'Unauthorized' } \ No newline at end of file diff --git a/src/i18n/registration.ts b/src/i18n/registration.ts new file mode 100644 index 0000000..6e84f2a --- /dev/null +++ b/src/i18n/registration.ts @@ -0,0 +1,5 @@ +export enum Registration { + BAD_REQUEST = 'Missing required fields', + INCORRECT_FIELD = 'Field type not in expected format', + INCORRECT_REQUEST = 'register mutation is required' +} \ No newline at end of file diff --git a/src/i18n/routes.ts b/src/i18n/routes.ts new file mode 100644 index 0000000..766828d --- /dev/null +++ b/src/i18n/routes.ts @@ -0,0 +1,3 @@ +export enum Routes { + CONTENT_TYPE = 'Content-Type is incorrect, __ required' +} \ No newline at end of file diff --git a/src/i18n/user.ts b/src/i18n/user.ts new file mode 100644 index 0000000..6f53a1a --- /dev/null +++ b/src/i18n/user.ts @@ -0,0 +1,4 @@ +export enum Error { + INCORRECT_FIELD = 'Field does not match requirements', + NO_FIELDS = 'No fields match required parameters' +} \ No newline at end of file diff --git a/src/routes/0.0.1/user/index.ts b/src/routes/0.0.1/user/index.ts index b2f655b..9209672 100644 --- a/src/routes/0.0.1/user/index.ts +++ b/src/routes/0.0.1/user/index.ts @@ -1,28 +1,77 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; const { graphql } = require('graphql'); +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { resolvers } from '@entities/user/resolvers'; import { schema } from '@entities/user/schema'; +import { parse } from '@helpers/body'; +import { contentTypeCheck } from '@helpers/headers'; +import { response } from '@helpers/response'; +import { Authorizer as auth_lang } from '@i18n/authorizer'; +import { Registration as reg_lang } from '@i18n/registration'; +import { User } from '@services/user'; + +export const graph = async (event: APIGatewayProxyEvent): Promise => { + let request; + + try { + contentTypeCheck(event.headers['Content-Type'], 'application/json'); + request = await graphql(schema, parse(event.body), resolvers); + } catch (error) { + console.log('exception'); + return response(400, error, true); + } finally { + if (request.hasOwnProperty('errors') && request.errors.length) { + return response(400, request.errors[0]); + } + } + + return response(200, { ...request }); +} -export const routes = { - graph: async (event: APIGatewayProxyEvent): Promise => { - let body = {}; - const response = { statusCode: 200 }; - - try { - const query = await graphql(schema, event.body, resolvers); - body = { ...query.data }; - } catch (error) { - response.statusCode = 500; - body = { error: error }; +export const login = async (event: APIGatewayProxyEvent): Promise => { + try { + if (!event.body) { + const [email, password] = Buffer.from(event.headers.Authorization.split(' ')[1], 'base64').toString().split(':'); + const mfa_challenge = await User.auth(email, password); + const oob_code = await User.oob(mfa_challenge); + + return response(200, { mfa_challenge, oob_code }); + } else { + const { mfa_challenge, oob_code, mfa_code } = JSON.parse(event.body); + + if (mfa_challenge && oob_code && mfa_code) { + const authentication = await User.oauth(mfa_challenge, oob_code, mfa_code); + return response(200, { ...authentication }); + } else { + return response(403, auth_lang.UNAUTHORIZED); + } } + } catch (error) { + return response(400, error, true); + } +} + +export const associate = async (event: APIGatewayProxyEvent): Promise => { + const payload = JSON.parse(event.body); + + try { + const [email, password] = Buffer.from(event.headers.Authorization.split(' ')[1], 'base64').toString().split(':'); + const mfa_challenge = await User.auth(email, password, 'mfa', 'enroll'); + const oob_code = await User.associate(mfa_challenge, payload.phone_number); + return response(200, { mfa_challenge, oob_code }); + } catch (error) { + return response(400, error, true); + } +} + - return { ...response, body: JSON.stringify(body, null, 2) }; +export const register = async (event: APIGatewayProxyEvent): Promise => { + const payload = parse(event.body); - }, - register: async (): Promise => { - const payload = { statusCode: 200, body: { foo: "bar" } }; - return { ...payload, body: JSON.stringify(payload.body, null, 2) }; + if (payload.includes('register')) { + return graph(event); + } else { + return response(400, reg_lang.INCORRECT_REQUEST); } } \ No newline at end of file diff --git a/src/services/auth0.ts b/src/services/auth0.ts index 44744ff..03aa44b 100644 --- a/src/services/auth0.ts +++ b/src/services/auth0.ts @@ -1,8 +1,8 @@ import { AxiosResponse } from 'axios'; -import { HttpClient } from '@helpers/axios/interceptor'; +import { HttpClient as A0HttpClient } from '@helpers/axios/interceptor'; import { UserInfo } from '@helpers/interfaces/auth0'; -export class A0 extends HttpClient { +export class A0 extends A0HttpClient { constructor(domain:string, bearerToken: string, contentType?: string) { super(`https://${domain}`, bearerToken, contentType); } diff --git a/src/services/http.ts b/src/services/http.ts new file mode 100644 index 0000000..630c30d --- /dev/null +++ b/src/services/http.ts @@ -0,0 +1,18 @@ +import { AxiosResponse } from 'axios'; +import { HttpClient } from '@helpers/axios/http'; + +export class httpWrapper extends HttpClient { + constructor(domain:string, protocol = 'https') { + super(`${protocol}://${domain}`); + } + + public get(uri: string, headers?: { [key: string]: string }): Promise> { + const request = headers ? this.instance.get(uri, headers) : this.instance.get(uri); + return request; + } + + public post(uri: string, data: unknown, headers?: { [key: string]: string }): Promise> { + const request = headers ? this.instance.post(uri, data, { headers }) : this.instance.post(uri, data); + return request; + } +} \ No newline at end of file diff --git a/src/services/user.ts b/src/services/user.ts index d0aa2b2..1d2c131 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -1,7 +1,14 @@ import { DynamoClient } from '@helpers/databases/ddb'; import { Schema } from '@entities/user/table'; -import { user } from '@entities/user/interfaces'; +import { a0, requirements, user } from '@entities/user/interfaces'; + +import { auth0 as env } from '@helpers/config'; +import * as lang_registration from '@i18n/registration'; +import * as lang_user from '@i18n/user'; +import { httpWrapper as Http } from '@services/http'; + +const lang = { ...lang_registration, ...lang_user }; function schema(_target: unknown, _key: string, descriptor: PropertyDescriptor) { const ctx = descriptor.value; @@ -31,25 +38,164 @@ function whitelist(method: string[]) { } export class User extends DynamoClient { + protected readonly requirements: requirements = { + delete: ['email'], + get: ['email'], + put: ['email', 'sub'], + register: ['email', 'name', 'password'], + update: ['email', 'sub'] + } + + protected readonly regex = { + email: (v: string): boolean => /^([\w\W]+)[@]((\w)+\.){1,}([\w]{2,})+$/.test(v), + name: (v: string): boolean => /([a-zA-Z]+){2}/.test(v), + password: (v: string): boolean => /^(?=.*?[A-Z])(?=(.*[a-z]){1,})(?=(.*[\d]){1,})(?=(.*[\W]){1,})(?!.*\s).{8,}$/.test(v), + phone: (v: string): boolean => /^([+]1)(\d{10})$/.test(v), + sub: (v: string): boolean => /^(\w+)\|(\w+$)/.test(v) + }; + + public static associate(token: string, phone: string): Promise { + return new Promise(async (resolve, reject) => { + let request; + const http = new Http(env.domain); + + try { + request = await http.post('/mfa/associate', { + authenticator_types: ['oob'], + client_id: env.id, + client_secret: env.secret, + oob_channels: ['sms'], + phone_number: phone + }, { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }); + } catch (error) { + console.error('associate', error); + return reject(error); + } + + resolve(request.oob_code); + }); + } + + public static auth(email: string, password: string, audience = 'api/v2', scope?: string): Promise { + return new Promise(async (resolve, reject) => { + let request; + const tld = env.domain; + const http = new Http(tld); + + try { + request = await http.post('/oauth/token', { + grant_type: 'password', + username: email, + password: password, + audience: `https://${tld}/${audience.replace(/\/$/,'')}/`, + scope: scope || 'read:current_user update:current_user_metadata delete:current_user_metadata create:current_user_metadata openid', + client_id: env.id, + client_secret: env.secret + }); + } catch (error) { + if (error.error === 'mfa_required') { + request = { mfa_token: error.mfa_token }; + } else { + console.error('auth', error); + return reject(error); + } + } + + resolve(request.mfa_token); + }); + } + ensureTableExists(config = { readCapacityUnits: 5, writeCapacityUnits: 5 }): Promise { return new Promise((resolve, reject) => { this.connection.ensureTableExists(Schema, config) .then(resolve) - .catch(error => { - reject(error); - }); + .catch(reject); }); } - + @whitelist(['delete', 'get', 'put', 'update']) @schema execute(...args: unknown[]): Promise { const method: string = args[0].toString(); const payload = args[1]; - return new Promise((resolve, reject) => { - this.connection[method](payload) - .then(resolve).catch(reject); + this.validate(payload, method); + + const fields = Object.keys(payload); + if (!fields.length) { + throw new Error(lang.Error.NO_FIELDS); + } + + return new Promise((resolve, reject) => this.connection[method](payload).then(resolve).catch(reject)); + } + + public static oauth(challenge: string, oob: string, mfa: string): Promise { + return new Promise(async (resolve, reject) => { + let request; + const http = new Http(env.domain); + + try { + request = await http.post('/oauth/token', { + mfa_token: challenge, + oob_code: oob, + binding_code: mfa, + grant_type: 'http://auth0.com/oauth/grant-type/mfa-oob', + client_id: env.id, + client_secret: env.secret + }); + } catch (error) { + console.error('oauth', error); + return reject(error); + } + + resolve(request); + }); + } + + public static oob(token: string): Promise { + return new Promise(async (resolve, reject) => { + let request; + const http = new Http(env.domain); + + try { + request = await http.post('/mfa/challenge', { + mfa_token: token, + challenge_type: 'oob', + client_id: env.id, + client_secret: env.secret + }); + } catch (error) { + console.error('oob', error); + return reject(error); + } + + resolve(request.oob_code); + }); + } + + async register(payload: { [key: string]: string }): Promise { + return new Promise(async (resolve, reject) => { + let request; + const http = new Http(env.domain); + + try { + this.validate(payload, 'register'); + request = await http.post('/dbconnections/signup', { + client_id: env.id, + connection: 'Username-Password-Authentication', + email: payload.email, + ...(payload.name && { name: payload.name }), + password: payload.password + }); + } catch (error) { + console.error('register', error); + return reject(error); + } + + resolve(request); }); } @@ -59,9 +205,18 @@ export class User extends DynamoClient { haystack.push(record); } } catch (error) { + console.error('query', error) throw new Error(error); } return haystack; } + + private validate(payload: unknown, method: string) { + for (const [k, v] of Object.entries(payload)) { + if (this.requirements[method].includes(k) && (this.regex.hasOwnProperty(k) && !this.regex[k](v))) { + throw new Error(`${lang.Error.INCORRECT_FIELD}: [${k}, ${v}]`); + } + } + } } \ No newline at end of file diff --git a/src/streams/users.ts b/src/streams/users.ts index b3b81aa..2036340 100644 --- a/src/streams/users.ts +++ b/src/streams/users.ts @@ -2,19 +2,38 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as AWS from 'aws-sdk'; +import { v4 as uuid } from 'uuid'; const lambda = new AWS.Lambda(); +const step = new AWS.StepFunctions(); export const stream = (event, context): Promise => { return new Promise(async (resolve, reject) => { try { event.Records.forEach(async (record, idx) => { + let params; + if (!idx) { - console.log('trigger', record.dynamodb.NewImage); - resolve(record.dynamodb.NewImage); + if (record.dynamodb.NewImage) { + params = { + stateMachineArn: process.env.REGISTRATION_ARN, + input: JSON.stringify(record), + name: uuid() + } + + step.startExecution(params, (error, data) => { + if (error) { + return reject(error); + } + + resolve(data); + }); + } else { + return resolve(); + } } else { event.Records = [record]; - const params = { + params = { FunctionName: context.functionName, InvocationType: 'Event', Payload: JSON.stringify(event), @@ -22,9 +41,7 @@ export const stream = (event, context): Promise => { } const invoke = lambda.invoke(params).promise(); - invoke.then(data => { - resolve(data); - }).catch(reject); + invoke.then(resolve).catch(reject); } }); } catch (error) { diff --git a/src/tasks/bi.ts b/src/tasks/bi.ts new file mode 100644 index 0000000..8e4bb7d --- /dev/null +++ b/src/tasks/bi.ts @@ -0,0 +1,4 @@ +export const beacon = async (event: unknown, _context: unknown, callback: (e: unknown, s: unknown) => void): Promise => { + console.log('beacon', event); + callback(null, true); +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d41dfff..9e5cf2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,8 @@ "@i18n/*": ["src/i18n/*"], "@routes/*": ["src/routes/*"], "@services/*": ["src/services/*"], - "@streams/*": ["src/streams/*"] + "@streams/*": ["src/streams/*"], + "@tasks/*": ["src/tasks/*"] } }, "include": ["./**/*.ts"], diff --git a/wallaby.js b/wallaby.js index 9b0eb0d..005e155 100644 --- a/wallaby.js +++ b/wallaby.js @@ -22,6 +22,7 @@ module.exports = w => { 'src/routes/*.ts', 'src/services/*.ts', 'src/streams/*.ts', + 'src/tasks/*.ts', 'src/entities/*/*.ts', 'src/helpers/*/*.ts' ], diff --git a/webpack.config.js b/webpack.config.js index e8e000e..e44788a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,7 +20,8 @@ module.exports = { '@i18n': path.resolve(__dirname, 'src', 'i18n'), '@routes': path.resolve(__dirname, 'src', 'routes'), '@services': path.resolve(__dirname, 'src', 'services'), - '@streams': path.resolve(__dirname, 'src', 'streams') + '@streams': path.resolve(__dirname, 'src', 'streams'), + '@tasks': path.resolve(__dirname, 'src', 'tasks') }, modules: ['node_modules'] },