@live-change/access-control-frontend 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/e2e/codecept.conf.js +60 -0
- package/e2e/steps.d.ts +12 -0
- package/e2e/steps_file.js +11 -0
- package/front/index.html +11 -0
- package/front/public/favicon.ico +0 -0
- package/front/public/images/empty-photo.svg +38 -0
- package/front/public/images/empty-user-photo.svg +33 -0
- package/front/public/images/logo.svg +34 -0
- package/front/public/images/logo128.png +0 -0
- package/front/src/App.vue +34 -0
- package/front/src/NavBar.vue +103 -0
- package/front/src/components/LimitedAccess.vue +10 -0
- package/front/src/configuration/AccessControl.vue +117 -0
- package/front/src/configuration/AccessInvitations.vue +118 -0
- package/front/src/configuration/AccessList.vue +119 -0
- package/front/src/configuration/AccessRequests.vue +132 -0
- package/front/src/configuration/PublicAccess.vue +123 -0
- package/front/src/configuration/routes.js +10 -0
- package/front/src/entry-client.js +6 -0
- package/front/src/entry-server.js +6 -0
- package/front/src/invite/InviteDialog.vue +117 -0
- package/front/src/invite/InviteEmail.vue +112 -0
- package/front/src/invite/routes.js +11 -0
- package/front/src/notifications/InviteNotification.vue +71 -0
- package/front/src/notifications/index.js +6 -0
- package/front/src/router.js +55 -0
- package/front/vite.config.js +11 -0
- package/package.json +75 -0
- package/server/init.js +84 -0
- package/server/security.config.js +53 -0
- package/server/services.config.js +84 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<SimpleNotification :notification="notification">
|
|
3
|
+
<div>
|
|
4
|
+
<UserIdentification :ownerType="notification.fromType" :owner="notification.from"
|
|
5
|
+
:data="notification.fromIdentification" inline />
|
|
6
|
+
Invited you to
|
|
7
|
+
<ObjectIdentification :objectType="notification.objectType" :object="notification.object" />
|
|
8
|
+
</div>
|
|
9
|
+
<div class="mt-2 ml-4" v-if="!notification.state">
|
|
10
|
+
<Button label="Accept" icon="pi pi-check" class="p-button-sm mr-2" @click="acceptInvitation" />
|
|
11
|
+
<Button label="Ignore" icon="pi pi-times" class="p-button-sm" @click="deleteNotification" />
|
|
12
|
+
</div>
|
|
13
|
+
<!-- <pre class="w-full overflow-hidden">{{ notification }}</pre>-->
|
|
14
|
+
</SimpleNotification>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup>
|
|
18
|
+
|
|
19
|
+
import { SimpleNotification, UserIdentification, ObjectIdentification } from "@live-change/user-frontend"
|
|
20
|
+
import Button from "primevue/button"
|
|
21
|
+
|
|
22
|
+
import { useToast } from 'primevue/usetoast'
|
|
23
|
+
import { useConfirm } from 'primevue/useconfirm'
|
|
24
|
+
const confirm = useConfirm()
|
|
25
|
+
const toast = useToast()
|
|
26
|
+
|
|
27
|
+
const { notification } = defineProps({
|
|
28
|
+
notification: {
|
|
29
|
+
type: Object,
|
|
30
|
+
required: true
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
import { inject } from "vue"
|
|
35
|
+
const workingZone = inject('workingZone')
|
|
36
|
+
|
|
37
|
+
import { actions } from "@live-change/vue3-ssr"
|
|
38
|
+
|
|
39
|
+
const notificationApi = actions().notification
|
|
40
|
+
const accessControlApi = actions().accessControl
|
|
41
|
+
|
|
42
|
+
function deleteNotification() {
|
|
43
|
+
workingZone.addPromise('deleteNotification', (async () => {
|
|
44
|
+
await notificationApi.delete({ notification: notification.to || notification.id })
|
|
45
|
+
toast.add({
|
|
46
|
+
severity: 'warn', summary: 'Notification deleted',
|
|
47
|
+
detail: 'Notification has been deleted', life: 3000
|
|
48
|
+
})
|
|
49
|
+
})())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function acceptInvitation() {
|
|
53
|
+
workingZone.addPromise('acceptInvitation', (async () => {
|
|
54
|
+
const { objectType, object } = notification
|
|
55
|
+
await Promise.all([
|
|
56
|
+
accessControlApi.acceptInvitation({ objectType, object }),
|
|
57
|
+
notificationApi.markRead({ notification: notification.to || notification.id }),
|
|
58
|
+
notificationApi.mark({ notification: notification.to || notification.id, state: 'accepted' })
|
|
59
|
+
])
|
|
60
|
+
toast.add({
|
|
61
|
+
severity: 'success', summary: 'Invitation Accepted',
|
|
62
|
+
detail: 'Invitation has been accepted', life: 3000
|
|
63
|
+
})
|
|
64
|
+
})())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<style scoped>
|
|
70
|
+
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMemoryHistory,
|
|
3
|
+
createRouter as _createRouter,
|
|
4
|
+
createWebHistory
|
|
5
|
+
} from 'vue-router'
|
|
6
|
+
|
|
7
|
+
import configurationRoutes from "./configuration/routes.js"
|
|
8
|
+
import inviteRoutes from "./invite/routes.js"
|
|
9
|
+
|
|
10
|
+
import { userRoutes, installUserRedirects } from "@live-change/user-frontend"
|
|
11
|
+
import { dbAdminRoutes } from "@live-change/db-admin"
|
|
12
|
+
|
|
13
|
+
export function routes(config = {}) {
|
|
14
|
+
console.log("DB ROUTES", dbAdminRoutes({ prefix: '/_db' }))
|
|
15
|
+
const { prefix = '/', route = (r) => r } = config
|
|
16
|
+
return [
|
|
17
|
+
...userRoutes({ ...config, prefix: prefix + 'user/' }),
|
|
18
|
+
|
|
19
|
+
...configurationRoutes(config),
|
|
20
|
+
...inviteRoutes(config),
|
|
21
|
+
|
|
22
|
+
route({
|
|
23
|
+
name: 'accessControl:testPage', path: prefix + '', meta: { },
|
|
24
|
+
component: () => import("./configuration/AccessControl.vue"),
|
|
25
|
+
props: {
|
|
26
|
+
objectType: 'example_Example',
|
|
27
|
+
object: 'one'
|
|
28
|
+
}
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
...dbAdminRoutes({ prefix: '/_db', route: r => ({ ...r, meta: { ...r.meta, raw: true }}) })
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function sitemap(route, api) {
|
|
36
|
+
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
import { client as useClient } from '@live-change/vue3-ssr'
|
|
40
|
+
import messageAuthRoutes from "../../../user-frontend/front/src/message-auth/routes";
|
|
41
|
+
import signRoutes from "../../../user-frontend/front/src/sign/routes";
|
|
42
|
+
|
|
43
|
+
export function createRouter(app, config) {
|
|
44
|
+
//console.log("APP CTX", app._context)
|
|
45
|
+
const client = useClient(app._context)
|
|
46
|
+
const router = _createRouter({
|
|
47
|
+
// use appropriate history implementation for server/client
|
|
48
|
+
// import.meta.env.SSR is injected by Vite.
|
|
49
|
+
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
|
|
50
|
+
routes: routes(config)
|
|
51
|
+
})
|
|
52
|
+
installUserRedirects(router, app, config)
|
|
53
|
+
return router
|
|
54
|
+
}
|
|
55
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@live-change/access-control-frontend",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"memDev": "lcli memDev --enableSessions --initScript ./init.js --dbAccess",
|
|
6
|
+
"localDevInit": "rm tmp.db; lcli localDev --enableSessions --initScript ./init.js",
|
|
7
|
+
"localDev": "lcli localDev --enableSessions",
|
|
8
|
+
"dev": "lcli dev --enableSessions",
|
|
9
|
+
"ssrDev": "lcli ssrDev --enableSessions",
|
|
10
|
+
"serveAllMem": "cross-env NODE_ENV=production lcli ssrServer --withApi --withServices --updateServices --enableSessions --withDb --dbBackend mem --createDb",
|
|
11
|
+
"serveAll": "cross-env NODE_ENV=production lcli ssrServer --withApi --withServices --updateServices --enableSessions",
|
|
12
|
+
"serve": "cross-env NODE_ENV=production lcli ssrServer --enableSessions",
|
|
13
|
+
"apiServer": "lcli apiServer --enableSessions",
|
|
14
|
+
"devApiServer": "lcli devApiServer --enableSessions",
|
|
15
|
+
"memApiServer": "lcli memApiServer --enableSessions",
|
|
16
|
+
"build": "cd front; yarn build:client && yarn build:server",
|
|
17
|
+
"build:client": "cd front; vite build --ssrManifest --outDir dist/client",
|
|
18
|
+
"build:server": "cd front; vite build --ssr src/entry-server.js --outDir dist/server",
|
|
19
|
+
"generate": "vite build --ssrManifest --outDir dist/static && yarn build:server && node prerender",
|
|
20
|
+
"debug": "node --inspect-brk server"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@live-change/framework": "0.6.5",
|
|
24
|
+
"@live-change/cli": "0.6.5",
|
|
25
|
+
"@live-change/dao": "0.4.13",
|
|
26
|
+
"@live-change/dao-vue3": "0.4.13",
|
|
27
|
+
"@live-change/dao-websocket": "0.4.13",
|
|
28
|
+
"@live-change/vue3-ssr": "0.2.14",
|
|
29
|
+
"@live-change/vue3-components": "0.2.12",
|
|
30
|
+
"@live-change/session-service": "0.2.39",
|
|
31
|
+
"@live-change/user-service": "0.2.37",
|
|
32
|
+
"@live-change/password-authentication-service": "0.2.39",
|
|
33
|
+
"@live-change/secret-link-service": "0.2.39",
|
|
34
|
+
"@live-change/secret-code-service": "0.2.39",
|
|
35
|
+
"@live-change/access-control-service": "0.2.37",
|
|
36
|
+
"@live-change/user-frontend": "0.0.3",
|
|
37
|
+
"@live-change/db-admin": "0.5.12",
|
|
38
|
+
"@vitejs/plugin-vue": "^2.3.1",
|
|
39
|
+
"@vitejs/plugin-vue-jsx": "^1.3.10",
|
|
40
|
+
"@vue/compiler-sfc": "^3.2.33",
|
|
41
|
+
"@vueuse/core": "^8.3.1",
|
|
42
|
+
"v-shared-element": "3.1.0",
|
|
43
|
+
"vue3-scroll-border": "0.1.2",
|
|
44
|
+
"codeceptjs-assert": "^0.0.5",
|
|
45
|
+
"compression": "^1.7.4",
|
|
46
|
+
"cross-env": "^7.0.3",
|
|
47
|
+
"get-port-sync": "1.0.1",
|
|
48
|
+
"primeicons": "^5.0.0",
|
|
49
|
+
"primevue": "^3.15.0",
|
|
50
|
+
"primeflex": "^3.2.1",
|
|
51
|
+
"rollup-plugin-node-builtins": "^2.1.2",
|
|
52
|
+
"serialize-javascript": "^6.0.0",
|
|
53
|
+
"serve-static": "^1.15.0",
|
|
54
|
+
"vite": "^2.9.6",
|
|
55
|
+
"vue": "^3.2.33",
|
|
56
|
+
"vue-meta": "^3.0.0-alpha.9",
|
|
57
|
+
"vue-router": "^4.0.14",
|
|
58
|
+
"vite-plugin-compression": "0.5.1",
|
|
59
|
+
"vite-plugin-vue-images": "^0.6.1",
|
|
60
|
+
"rollup-plugin-visualizer": "5.6.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@live-change/codeceptjs-helper": "0.6.5",
|
|
64
|
+
"@wdio/selenium-standalone-service": "^7.19.5",
|
|
65
|
+
"codeceptjs": "^3.3.1",
|
|
66
|
+
"playwright": "^1.21.1",
|
|
67
|
+
"random-profile-generator": "^2.3.0",
|
|
68
|
+
"txtgen": "^3.0.1",
|
|
69
|
+
"generate-password": "1.7.0",
|
|
70
|
+
"webdriverio": "^7.19.5"
|
|
71
|
+
},
|
|
72
|
+
"author": "",
|
|
73
|
+
"license": "ISC",
|
|
74
|
+
"description": ""
|
|
75
|
+
}
|
package/server/init.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const App = require('@live-change/framework')
|
|
2
|
+
const app = App.app()
|
|
3
|
+
|
|
4
|
+
module.exports = async function(services) {
|
|
5
|
+
|
|
6
|
+
const { PasswordAuthentication } = services.passwordAuthentication.models
|
|
7
|
+
const { PublicAccess, Access, AccessRequest, AccessInvitation } = services.accessControl.models
|
|
8
|
+
|
|
9
|
+
async function createUser(name, email, password, user = app.generateUid()) {
|
|
10
|
+
const passwordHash = PasswordAuthentication.definition.properties.passwordHash.preFilter(password)
|
|
11
|
+
await services.user.models.User.create({ id: user, roles: [] })
|
|
12
|
+
await PasswordAuthentication.create({ id: user, user, passwordHash })
|
|
13
|
+
await services.email.models.Email.create({ id: email, email, user })
|
|
14
|
+
await services.userIdentification.models.Identification.create({
|
|
15
|
+
id: App.encodeIdentifier(['user_User', user]), sessionOrUserType: 'user_User', sessionOrUser: user,
|
|
16
|
+
name
|
|
17
|
+
})
|
|
18
|
+
return {
|
|
19
|
+
id: user,
|
|
20
|
+
name,
|
|
21
|
+
email,
|
|
22
|
+
password
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const session = 'GOzz0WylRDklhLCSppS6bwRYUeIQJqzt'
|
|
27
|
+
|
|
28
|
+
//console.log("MDL", services.passwordAuthentication.models.PasswordAuthentication)
|
|
29
|
+
|
|
30
|
+
const user1 = await createUser('Test User 1', 'test1@test.com', 'Testy123', 'u1')
|
|
31
|
+
const user2 = await createUser('Test User 2 with very long name!', 'test2@test.com', 'Testy123')
|
|
32
|
+
const user3 = await createUser('Test User 3', 'test3@test.com', 'Testy123')
|
|
33
|
+
|
|
34
|
+
await services.user.models.AuthenticatedUser.create({ id: session, session, user: user2.id })
|
|
35
|
+
|
|
36
|
+
await services.notification.models.Notification.create({
|
|
37
|
+
"id": app.generateUid(),
|
|
38
|
+
"notificationType": "accessControl_Invitation",
|
|
39
|
+
"objectType": "example_Example",
|
|
40
|
+
"object": "two",
|
|
41
|
+
"fromType": "user_User",
|
|
42
|
+
"from": user1.id,
|
|
43
|
+
"sessionOrUserType": "user_User",
|
|
44
|
+
"sessionOrUser": user2.id,
|
|
45
|
+
"time": "2022-05-23T13:13:25.501Z",
|
|
46
|
+
"readState": "new"
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/* await PublicAccess.create({
|
|
50
|
+
id: App.encodeIdentifier(['example_Example', 'one']),
|
|
51
|
+
objectType: 'example_Example', object: 'one',
|
|
52
|
+
userRoles: ['reader'],
|
|
53
|
+
sessionRoles: []
|
|
54
|
+
})*/
|
|
55
|
+
|
|
56
|
+
await Access.create({
|
|
57
|
+
id: App.encodeIdentifier(['user_User', user1.id, 'example_Example', 'one']),
|
|
58
|
+
sessionOrUserType: 'user_User', sessionOrUser: user1.id,
|
|
59
|
+
objectType: 'example_Example', object: 'one',
|
|
60
|
+
roles: ['administrator']
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await AccessRequest.create({
|
|
64
|
+
id: App.encodeIdentifier(['user_User', user2.id, 'example_Example', 'one']),
|
|
65
|
+
sessionOrUserType: 'user_User', sessionOrUser: user2.id,
|
|
66
|
+
objectType: 'example_Example', object: 'one',
|
|
67
|
+
roles: ['writer']
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
await AccessInvitation.create({
|
|
71
|
+
id: App.encodeIdentifier(['user_User', user3.id, 'example_Example', 'one']),
|
|
72
|
+
contactOrUserType: 'user_User', contactOrUser: user3.id,
|
|
73
|
+
objectType: 'example_Example', object: 'one',
|
|
74
|
+
roles: ['moderator']
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await AccessInvitation.create({
|
|
78
|
+
id: App.encodeIdentifier(['email_Email', 'tester@test.com', 'example_Example', 'one']),
|
|
79
|
+
contactOrUserType: 'email_Email', contactOrUser: 'tester@test.com',
|
|
80
|
+
objectType: 'example_Example', object: 'one',
|
|
81
|
+
roles: ['moderator']
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const lcp = require("@live-change/pattern")
|
|
2
|
+
|
|
3
|
+
const clientKeys = (client) => [
|
|
4
|
+
{ key: 'user', value: client.user },
|
|
5
|
+
{ key: 'session', value: client.session },
|
|
6
|
+
{ key: 'ip', value: client.ip }
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
const failedAuthCodes = lcp.chain([
|
|
10
|
+
{ type: "wrong-secret-code", id: "1st-failed-secret-code" },
|
|
11
|
+
{ eq: "ip", expire: "10m" },
|
|
12
|
+
{ type: "wrong-secret-code", id: "2nd-failed-secret-code" },
|
|
13
|
+
{ eq: "ip", expire: "10m" },
|
|
14
|
+
{ type: "wrong-secret-code", id: "3rd-failed-secret-code",
|
|
15
|
+
actions: [
|
|
16
|
+
{ type: 'ban', keys: ['ip'], ban: { type: 'captcha', actions: ['checkSecretCode'], expire: "30m" } }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
]).model
|
|
20
|
+
|
|
21
|
+
const patterns = lcp.mergeModels(
|
|
22
|
+
//failedAuthCodes
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const counters = [
|
|
26
|
+
{
|
|
27
|
+
id: 'wrong-codes-captcha',
|
|
28
|
+
match: ['wrong-secret-code'],
|
|
29
|
+
keys: ['ip'],
|
|
30
|
+
max: 2,
|
|
31
|
+
duration: '1m',
|
|
32
|
+
actions: [
|
|
33
|
+
{ type: 'ban', keys: ['ip'], ban: { type: 'captcha', actions: ['checkSecretCode'], expire: "30m" } }
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'wrong-codes-ban',
|
|
38
|
+
visible: true,
|
|
39
|
+
match: ['wrong-secret-code'],
|
|
40
|
+
keys: ['ip'],
|
|
41
|
+
max: 5,
|
|
42
|
+
duration: '10m',
|
|
43
|
+
actions: [
|
|
44
|
+
{ type: 'ban', keys: ['ip'], ban: { type: 'block', actions: ['checkSecretCode'], expire: "2m" } }
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
clientKeys,
|
|
51
|
+
patterns,
|
|
52
|
+
counters
|
|
53
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const contactTypes = ['email']
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
services: [
|
|
5
|
+
{
|
|
6
|
+
name: 'timer',
|
|
7
|
+
path: '@live-change/timer-service'
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
name: 'session',
|
|
11
|
+
path: '@live-change/session-service',
|
|
12
|
+
createSessionOnUpdate: true
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'user',
|
|
16
|
+
path: '@live-change/user-service'
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'email',
|
|
20
|
+
path: '@live-change/email-service'
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'security',
|
|
24
|
+
path: '@live-change/security-service',
|
|
25
|
+
...require('./security.config.js')
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'secretLink',
|
|
29
|
+
path: '@live-change/secret-link-service'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'secretCode',
|
|
33
|
+
path: '@live-change/secret-code-service'
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'messageAuthentication',
|
|
37
|
+
path: '@live-change/message-authentication-service',
|
|
38
|
+
contactTypes,
|
|
39
|
+
signUp: true,
|
|
40
|
+
signIn: true,
|
|
41
|
+
connect: true
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'passwordAuthentication',
|
|
45
|
+
path: '@live-change/password-authentication-service',
|
|
46
|
+
contactTypes,
|
|
47
|
+
signInWithoutPassword: true
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'userIdentification',
|
|
51
|
+
path: '@live-change/user-identification-service'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'identicon',
|
|
55
|
+
path: '@live-change/identicon-service'
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'accessControl',
|
|
59
|
+
path: '@live-change/access-control-service',
|
|
60
|
+
createSessionOnUpdate: true,
|
|
61
|
+
contactTypes,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'notification',
|
|
65
|
+
path: '@live-change/notification-service',
|
|
66
|
+
contactTypes,
|
|
67
|
+
notificationTypes: ['accessControl_Invitation'],
|
|
68
|
+
fields: {
|
|
69
|
+
objectType: {
|
|
70
|
+
type: String
|
|
71
|
+
},
|
|
72
|
+
object: {
|
|
73
|
+
type: String
|
|
74
|
+
},
|
|
75
|
+
fromType: {
|
|
76
|
+
type: String
|
|
77
|
+
},
|
|
78
|
+
from: {
|
|
79
|
+
type: String
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
}
|