Unfurling of Slack links (#487)
* First pass: Unfurling of Slack links * Add authentication in db * Call associate on Event correctly * Add SLACK_APP_ID, remove SLACK_REDIRECT_URI, tidy env sample * PR feedback * Comment clarify
This commit is contained in:
19
.env.sample
19
.env.sample
@ -1,4 +1,6 @@
|
|||||||
# Copy this file to .env, remove this comment and change the keys
|
# Copy this file to .env, remove this comment and change the keys. For development
|
||||||
|
# with docker this should mostly work out of the box other than setting the Slack
|
||||||
|
# keys (for auth) and the SECRET_KEY.
|
||||||
#
|
#
|
||||||
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
# Please use `openssl rand -hex 32` to create SECRET_KEY
|
||||||
|
|
||||||
@ -7,19 +9,28 @@ DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
|
|||||||
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||||
PORT=3000
|
PORT=3000
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
SLACK_KEY=71315967491.XXXXXXXXXX
|
|
||||||
SLACK_SECRET=d2dc414f9953226bad0a356c794XXXXX
|
|
||||||
URL=http://localhost:3000
|
URL=http://localhost:3000
|
||||||
DEPLOYMENT=hosted
|
DEPLOYMENT=hosted
|
||||||
ENABLE_UPDATES=true
|
ENABLE_UPDATES=true
|
||||||
GOOGLE_ANALYTICS_ID=
|
|
||||||
|
|
||||||
|
# Third party credentials (required)
|
||||||
|
SLACK_KEY=71315967491.XXXXXXXXXX
|
||||||
|
SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY
|
||||||
|
|
||||||
|
# Third party credentials (optional)
|
||||||
|
SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY
|
||||||
|
SLACK_APP_ID=A0XXXXXXX
|
||||||
|
GOOGLE_ANALYTICS_ID=
|
||||||
|
BUGSNAG_KEY=
|
||||||
|
|
||||||
|
# AWS credentials (optional in dev)
|
||||||
AWS_ACCESS_KEY_ID=notcheckedindev
|
AWS_ACCESS_KEY_ID=notcheckedindev
|
||||||
AWS_SECRET_ACCESS_KEY=notcheckedindev
|
AWS_SECRET_ACCESS_KEY=notcheckedindev
|
||||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||||
AWS_S3_UPLOAD_BUCKET_NAME=outline-dev
|
AWS_S3_UPLOAD_BUCKET_NAME=outline-dev
|
||||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||||
|
|
||||||
|
# Emails configuration (optional)
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=
|
SMTP_PORT=
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
|
@ -54,7 +54,6 @@
|
|||||||
"globals": {
|
"globals": {
|
||||||
"__DEV__": true,
|
"__DEV__": true,
|
||||||
"SLACK_KEY": true,
|
"SLACK_KEY": true,
|
||||||
"SLACK_REDIRECT_URI": true,
|
|
||||||
"DEPLOYMENT": true,
|
"DEPLOYMENT": true,
|
||||||
"BASE_URL": true,
|
"BASE_URL": true,
|
||||||
"BUGSNAG_KEY": true,
|
"BUGSNAG_KEY": true,
|
||||||
|
@ -17,11 +17,11 @@ class Slack extends Component {
|
|||||||
<h1>Slack</h1>
|
<h1>Slack</h1>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Connect Outline to your Slack team to instantly search for documents
|
Connect Outline to your Slack team to instantly search for documents
|
||||||
using the <Code>/outline</Code> command.
|
using the <Code>/outline</Code> command and preview Outline links.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
|
||||||
<SlackButton
|
<SlackButton
|
||||||
scopes={['commands']}
|
scopes={['commands', 'links:read', 'links:write']}
|
||||||
redirectUri={`${BASE_URL}/auth/slack/commands`}
|
redirectUri={`${BASE_URL}/auth/slack/commands`}
|
||||||
/>
|
/>
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
|
2
flow-typed/globals.js
vendored
2
flow-typed/globals.js
vendored
@ -1,7 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
declare var __DEV__: string;
|
declare var __DEV__: string;
|
||||||
declare var SLACK_REDIRECT_URI: string;
|
|
||||||
declare var SLACK_KEY: string;
|
declare var SLACK_KEY: string;
|
||||||
|
declare var SLACK_APP_ID: string;
|
||||||
declare var BASE_URL: string;
|
declare var BASE_URL: string;
|
||||||
declare var BUGSNAG_KEY: ?string;
|
declare var BUGSNAG_KEY: ?string;
|
||||||
declare var DEPLOYMENT: string;
|
declare var DEPLOYMENT: string;
|
||||||
|
@ -169,6 +169,7 @@
|
|||||||
"slate-edit-list": "^0.10.1",
|
"slate-edit-list": "^0.10.1",
|
||||||
"slate-md-serializer": "^1.0.6",
|
"slate-md-serializer": "^1.0.6",
|
||||||
"slate-paste-linkify": "^0.5.0",
|
"slate-paste-linkify": "^0.5.0",
|
||||||
|
"slate-plain-serializer": "^0.4.15",
|
||||||
"slate-prism": "^0.4.0",
|
"slate-prism": "^0.4.0",
|
||||||
"slate-react": "^0.10.19",
|
"slate-react": "^0.10.19",
|
||||||
"slate-trailing-block": "^0.4.0",
|
"slate-trailing-block": "^0.4.0",
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import auth from './middlewares/authentication';
|
import auth from './middlewares/authentication';
|
||||||
import { presentUser, presentTeam } from '../presenters';
|
import { presentUser, presentTeam } from '../presenters';
|
||||||
import { User, Team } from '../models';
|
import { Authentication, User, Team } from '../models';
|
||||||
import * as Slack from '../slack';
|
import * as Slack from '../slack';
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@ -81,11 +81,21 @@ router.post('auth.slack', async ctx => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('auth.slackCommands', async ctx => {
|
router.post('auth.slackCommands', auth(), async ctx => {
|
||||||
const { code } = ctx.body;
|
const { code } = ctx.body;
|
||||||
ctx.assertPresent(code, 'code is required');
|
ctx.assertPresent(code, 'code is required');
|
||||||
|
|
||||||
await Slack.oauthAccess(code, `${process.env.URL || ''}/auth/slack/commands`);
|
const user = ctx.state.user;
|
||||||
|
const endpoint = `${process.env.URL || ''}/auth/slack/commands`;
|
||||||
|
const data = await Slack.oauthAccess(code, endpoint);
|
||||||
|
|
||||||
|
await Authentication.create({
|
||||||
|
serviceId: 'slack',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
token: data.access_token,
|
||||||
|
scopes: data.scope.split(','),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
@ -1,10 +1,48 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import httpErrors from 'http-errors';
|
import httpErrors from 'http-errors';
|
||||||
import { Document, User } from '../models';
|
import { Authentication, Document, User } from '../models';
|
||||||
|
import * as Slack from '../slack';
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post('hooks.unfurl', async ctx => {
|
||||||
|
const { challenge, token, event } = ctx.body;
|
||||||
|
if (challenge) return (ctx.body = ctx.body.challenge);
|
||||||
|
|
||||||
|
if (token !== process.env.SLACK_VERIFICATION_TOKEN)
|
||||||
|
throw httpErrors.BadRequest('Invalid token');
|
||||||
|
|
||||||
|
// TODO: Everything from here onwards will get moved to an async job
|
||||||
|
const user = await User.find({ where: { slackId: event.user } });
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const auth = await Authentication.find({
|
||||||
|
where: { serviceId: 'slack', teamId: user.teamId },
|
||||||
|
});
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
// get content for unfurled links
|
||||||
|
let unfurls = {};
|
||||||
|
for (let link of event.links) {
|
||||||
|
const id = link.url.substr(link.url.lastIndexOf('/') + 1);
|
||||||
|
const doc = await Document.findById(id);
|
||||||
|
if (!doc || doc.teamId !== user.teamId) continue;
|
||||||
|
|
||||||
|
unfurls[link.url] = {
|
||||||
|
title: doc.title,
|
||||||
|
text: doc.getSummary(),
|
||||||
|
color: doc.collection.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await Slack.post('chat.unfurl', {
|
||||||
|
token: auth.token,
|
||||||
|
channel: event.channel,
|
||||||
|
ts: event.message_ts,
|
||||||
|
unfurls,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.post('hooks.slack', async ctx => {
|
router.post('hooks.slack', async ctx => {
|
||||||
const { token, user_id, text } = ctx.body;
|
const { token, user_id, text } = ctx.body;
|
||||||
ctx.assertPresent(token, 'token is required');
|
ctx.assertPresent(token, 'token is required');
|
||||||
|
@ -10,11 +10,13 @@ export default function apiWrapper() {
|
|||||||
|
|
||||||
const ok = ctx.status < 400;
|
const ok = ctx.status < 400;
|
||||||
|
|
||||||
// $FlowFixMe
|
if (typeof ctx.body !== 'string') {
|
||||||
ctx.body = {
|
// $FlowFixMe
|
||||||
...ctx.body,
|
ctx.body = {
|
||||||
status: ctx.status,
|
...ctx.body,
|
||||||
ok,
|
status: ctx.status,
|
||||||
};
|
ok,
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
49
server/migrations/20171218043717-add-authentications.js
Normal file
49
server/migrations/20171218043717-add-authentications.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.createTable('authentications', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'teams',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceId: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
token: {
|
||||||
|
type: Sequelize.BLOB,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
scopes: {
|
||||||
|
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.dropTable('authentications');
|
||||||
|
},
|
||||||
|
};
|
26
server/models/Authentication.js
Normal file
26
server/models/Authentication.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// @flow
|
||||||
|
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
|
||||||
|
|
||||||
|
const Authentication = sequelize.define('authentication', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
serviceId: DataTypes.STRING,
|
||||||
|
scopes: DataTypes.ARRAY(DataTypes.STRING),
|
||||||
|
token: encryptedFields.vault('token'),
|
||||||
|
});
|
||||||
|
|
||||||
|
Authentication.associate = models => {
|
||||||
|
Authentication.belongsTo(models.User, {
|
||||||
|
as: 'user',
|
||||||
|
foreignKey: 'userId',
|
||||||
|
});
|
||||||
|
Authentication.belongsTo(models.Team, {
|
||||||
|
as: 'team',
|
||||||
|
foreignKey: 'teamId',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Authentication;
|
@ -2,12 +2,15 @@
|
|||||||
import slug from 'slug';
|
import slug from 'slug';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import randomstring from 'randomstring';
|
import randomstring from 'randomstring';
|
||||||
|
import MarkdownSerializer from 'slate-md-serializer';
|
||||||
|
import Plain from 'slate-plain-serializer';
|
||||||
|
|
||||||
import isUUID from 'validator/lib/isUUID';
|
import isUUID from 'validator/lib/isUUID';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
import parseTitle from '../../shared/utils/parseTitle';
|
import parseTitle from '../../shared/utils/parseTitle';
|
||||||
import Revision from './Revision';
|
import Revision from './Revision';
|
||||||
|
|
||||||
|
const Markdown = new MarkdownSerializer();
|
||||||
const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/;
|
const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/;
|
||||||
|
|
||||||
// $FlowIssue invalid flow-typed
|
// $FlowIssue invalid flow-typed
|
||||||
@ -203,6 +206,13 @@ Document.searchForUser = async (
|
|||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
|
||||||
|
Document.prototype.getSummary = function() {
|
||||||
|
const value = Markdown.deserialize(this.text);
|
||||||
|
const plain = Plain.serialize(value);
|
||||||
|
const lines = _.compact(plain.split('\n'));
|
||||||
|
return lines.length >= 1 ? lines[1] : '';
|
||||||
|
};
|
||||||
|
|
||||||
Document.prototype.getUrl = function() {
|
Document.prototype.getUrl = function() {
|
||||||
const slugifiedTitle = slugify(this.title);
|
const slugifiedTitle = slugify(this.title);
|
||||||
return `/doc/${slugifiedTitle}-${this.urlId}`;
|
return `/doc/${slugifiedTitle}-${this.urlId}`;
|
||||||
|
@ -9,30 +9,6 @@ const Event = sequelize.define('event', {
|
|||||||
},
|
},
|
||||||
name: DataTypes.STRING,
|
name: DataTypes.STRING,
|
||||||
data: DataTypes.JSONB,
|
data: DataTypes.JSONB,
|
||||||
|
|
||||||
userId: {
|
|
||||||
type: 'UUID',
|
|
||||||
allowNull: true,
|
|
||||||
references: {
|
|
||||||
model: 'users',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
collectionId: {
|
|
||||||
type: 'UUID',
|
|
||||||
allowNull: true,
|
|
||||||
references: {
|
|
||||||
model: 'collections',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
teamId: {
|
|
||||||
type: 'UUID',
|
|
||||||
allowNull: true,
|
|
||||||
references: {
|
|
||||||
model: 'teams',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Event.associate = models => {
|
Event.associate = models => {
|
||||||
|
@ -2,12 +2,11 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
import JWT from 'jsonwebtoken';
|
||||||
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
|
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
|
||||||
import { uploadToS3FromUrl } from '../utils/s3';
|
import { uploadToS3FromUrl } from '../utils/s3';
|
||||||
import mailer from '../mailer';
|
import mailer from '../mailer';
|
||||||
|
|
||||||
import JWT from 'jsonwebtoken';
|
|
||||||
|
|
||||||
const BCRYPT_COST = process.env.NODE_ENV !== 'production' ? 4 : 12;
|
const BCRYPT_COST = process.env.NODE_ENV !== 'production' ? 4 : 12;
|
||||||
|
|
||||||
const User = sequelize.define(
|
const User = sequelize.define(
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
import Authentication from './Authentication';
|
||||||
|
import Event from './Event';
|
||||||
import User from './User';
|
import User from './User';
|
||||||
import Team from './Team';
|
import Team from './Team';
|
||||||
import Collection from './Collection';
|
import Collection from './Collection';
|
||||||
@ -9,6 +11,8 @@ import View from './View';
|
|||||||
import Star from './Star';
|
import Star from './Star';
|
||||||
|
|
||||||
const models = {
|
const models = {
|
||||||
|
Authentication,
|
||||||
|
Event,
|
||||||
User,
|
User,
|
||||||
Team,
|
Team,
|
||||||
Collection,
|
Collection,
|
||||||
@ -26,4 +30,15 @@ Object.keys(models).forEach(modelName => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { User, Team, Collection, Document, Revision, ApiKey, View, Star };
|
export {
|
||||||
|
Authentication,
|
||||||
|
Event,
|
||||||
|
User,
|
||||||
|
Team,
|
||||||
|
Collection,
|
||||||
|
Document,
|
||||||
|
Revision,
|
||||||
|
ApiKey,
|
||||||
|
View,
|
||||||
|
Star,
|
||||||
|
};
|
||||||
|
7
server/services/slack/index.js
Normal file
7
server/services/slack/index.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// @flow
|
||||||
|
const Slack = {
|
||||||
|
id: 'slack',
|
||||||
|
name: 'Slack',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Slack;
|
@ -5,6 +5,27 @@ import { httpErrors } from './errors';
|
|||||||
|
|
||||||
const SLACK_API_URL = 'https://slack.com/api';
|
const SLACK_API_URL = 'https://slack.com/api';
|
||||||
|
|
||||||
|
export async function post(endpoint: string, body: Object) {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const token = body.token;
|
||||||
|
const response = await fetch(`${SLACK_API_URL}/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
data = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
throw httpErrors.BadRequest();
|
||||||
|
}
|
||||||
|
if (!data.ok) throw httpErrors.BadRequest(data.error);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function request(endpoint: string, body: Object) {
|
export async function request(endpoint: string, body: Object) {
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>Outline</title>
|
<title>Outline</title>
|
||||||
|
<meta name="slack-app-id" content="<%= SLACK_APP_ID %>" />
|
||||||
<style>
|
<style>
|
||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
@ -28,4 +29,4 @@
|
|||||||
<script src="//d2wy8f7a9ursnm.cloudfront.net/bugsnag-3.min.js" data-apikey="<%= BUGSNAG_KEY %>"></script>
|
<script src="//d2wy8f7a9ursnm.cloudfront.net/bugsnag-3.min.js" data-apikey="<%= BUGSNAG_KEY %>"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
@ -10,7 +10,7 @@ const definePlugin = new webpack.DefinePlugin({
|
|||||||
__PRERELEASE__: JSON.stringify(
|
__PRERELEASE__: JSON.stringify(
|
||||||
JSON.parse(process.env.BUILD_PRERELEASE || 'false')
|
JSON.parse(process.env.BUILD_PRERELEASE || 'false')
|
||||||
),
|
),
|
||||||
SLACK_REDIRECT_URI: JSON.stringify(process.env.SLACK_REDIRECT_URI),
|
SLACK_APP_ID: JSON.stringify(process.env.SLACK_APP_ID),
|
||||||
BASE_URL: JSON.stringify(process.env.URL),
|
BASE_URL: JSON.stringify(process.env.URL),
|
||||||
BUGSNAG_KEY: JSON.stringify(process.env.BUGSNAG_KEY),
|
BUGSNAG_KEY: JSON.stringify(process.env.BUGSNAG_KEY),
|
||||||
DEPLOYMENT: JSON.stringify(process.env.DEPLOYMENT || 'hosted'),
|
DEPLOYMENT: JSON.stringify(process.env.DEPLOYMENT || 'hosted'),
|
||||||
|
@ -8264,6 +8264,12 @@ slate-plain-serializer@^0.4.12:
|
|||||||
dependencies:
|
dependencies:
|
||||||
slate-dev-logger "^0.1.36"
|
slate-dev-logger "^0.1.36"
|
||||||
|
|
||||||
|
slate-plain-serializer@^0.4.15:
|
||||||
|
version "0.4.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.4.15.tgz#bb4ff0594b7c56b88158d52ac99196972e5d0ba9"
|
||||||
|
dependencies:
|
||||||
|
slate-dev-logger "^0.1.36"
|
||||||
|
|
||||||
slate-prism@^0.4.0:
|
slate-prism@^0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.npmjs.org/slate-prism/-/slate-prism-0.4.0.tgz#9d43b1fafa4c3a8e3bceaa8dbc41dd5ca39445a9"
|
resolved "https://registry.npmjs.org/slate-prism/-/slate-prism-0.4.0.tgz#9d43b1fafa4c3a8e3bceaa8dbc41dd5ca39445a9"
|
||||||
|
Reference in New Issue
Block a user