@comapeo/core 2.3.2 → 3.0.0
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/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/invite/invite-api.d.ts +112 -0
- package/dist/invite/invite-api.d.ts.map +1 -0
- package/dist/invite/invite-state-machine.d.ts +510 -0
- package/dist/invite/invite-state-machine.d.ts.map +1 -0
- package/dist/local-peers.d.ts.map +1 -1
- package/dist/mapeo-manager.d.ts +1 -1
- package/dist/mapeo-manager.d.ts.map +1 -1
- package/dist/member-api.d.ts.map +1 -1
- package/dist/sync/sync-api.d.ts.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/errors.js +33 -0
- package/src/invite/StateDiagram.md +47 -0
- package/src/invite/invite-api.js +387 -0
- package/src/invite/invite-state-machine.js +208 -0
- package/src/local-peers.js +76 -30
- package/src/mapeo-manager.js +1 -1
- package/src/member-api.js +5 -4
- package/src/types.ts +6 -0
- package/src/utils.js +8 -3
- package/dist/invite-api.d.ts +0 -70
- package/dist/invite-api.d.ts.map +0 -1
- package/src/invite-api.js +0 -450
package/src/invite-api.js
DELETED
|
@@ -1,450 +0,0 @@
|
|
|
1
|
-
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
2
|
-
import { pEvent } from 'p-event'
|
|
3
|
-
import { InviteResponse_Decision } from './generated/rpc.js'
|
|
4
|
-
import { assert, keyToId, noop } from './utils.js'
|
|
5
|
-
import HashMap from './lib/hashmap.js'
|
|
6
|
-
import timingSafeEqual from 'string-timing-safe-equal'
|
|
7
|
-
import { Logger } from './logger.js'
|
|
8
|
-
/** @import { MapBuffers } from './types.js' */
|
|
9
|
-
/**
|
|
10
|
-
* @import {
|
|
11
|
-
* Invite as InviteRpcMessage,
|
|
12
|
-
* InviteCancel,
|
|
13
|
-
* ProjectJoinDetails
|
|
14
|
-
* } from './generated/rpc.js'
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
// There are three slightly different invite types:
|
|
18
|
-
//
|
|
19
|
-
// - InviteRpcMessage comes from the protobuf.
|
|
20
|
-
// - InviteInternal adds a locally-generated receive timestamp.
|
|
21
|
-
// - Invite is the externally-facing type.
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* @internal
|
|
25
|
-
* @typedef {InviteRpcMessage & { receivedAt: number }} InviteInternal
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** @typedef {MapBuffers<InviteInternal>} Invite */
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @typedef {(
|
|
32
|
-
* 'accepted' |
|
|
33
|
-
* 'rejected' |
|
|
34
|
-
* 'canceled' |
|
|
35
|
-
* 'accepted other' |
|
|
36
|
-
* 'connection error' |
|
|
37
|
-
* 'internal error'
|
|
38
|
-
* )} InviteRemovalReason
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Manage pending invite state.
|
|
43
|
-
*/
|
|
44
|
-
class PendingInvites {
|
|
45
|
-
/**
|
|
46
|
-
* @internal
|
|
47
|
-
* @typedef {object} PendingInvite
|
|
48
|
-
* @prop {string} peerId
|
|
49
|
-
* @prop {InviteInternal} invite
|
|
50
|
-
* @prop {boolean} isAccepting
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
/** @type {HashMap<Buffer, PendingInvite>} */
|
|
54
|
-
#byInviteId = new HashMap(keyToId)
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* @returns {Iterable<PendingInvite>} the pending invites, in insertion order
|
|
58
|
-
*/
|
|
59
|
-
invites() {
|
|
60
|
-
return this.#byInviteId.values()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* @param {PendingInvite} pendingInvite
|
|
65
|
-
* @throws if adding a duplicate invite ID
|
|
66
|
-
* @returns {void}
|
|
67
|
-
*/
|
|
68
|
-
add(pendingInvite) {
|
|
69
|
-
const {
|
|
70
|
-
invite: { inviteId },
|
|
71
|
-
} = pendingInvite
|
|
72
|
-
assert(!this.#byInviteId.has(inviteId), 'Added duplicate invite')
|
|
73
|
-
this.#byInviteId.set(inviteId, pendingInvite)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* @param {Buffer} inviteId
|
|
78
|
-
* @returns {void}
|
|
79
|
-
*/
|
|
80
|
-
markAccepting(inviteId) {
|
|
81
|
-
const pendingInvite = this.#byInviteId.get(inviteId)
|
|
82
|
-
assert(
|
|
83
|
-
!!pendingInvite,
|
|
84
|
-
`Couldn't find invite for ${inviteId.toString('hex')}`
|
|
85
|
-
)
|
|
86
|
-
this.#byInviteId.set(inviteId, { ...pendingInvite, isAccepting: true })
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* @param {Buffer} inviteId
|
|
91
|
-
* @returns {boolean}
|
|
92
|
-
*/
|
|
93
|
-
hasInviteId(inviteId) {
|
|
94
|
-
return this.#byInviteId.has(inviteId)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* @param {Readonly<Buffer>} projectInviteId
|
|
99
|
-
* @returns {boolean}
|
|
100
|
-
*/
|
|
101
|
-
isAcceptingForProject(projectInviteId) {
|
|
102
|
-
for (const { invite, isAccepting } of this.invites()) {
|
|
103
|
-
if (isAccepting && invite.projectInviteId.equals(projectInviteId)) {
|
|
104
|
-
return true
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return false
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* @param {Buffer} inviteId
|
|
112
|
-
* @returns {undefined | PendingInvite}
|
|
113
|
-
*/
|
|
114
|
-
getByInviteId(inviteId) {
|
|
115
|
-
return this.#byInviteId.get(inviteId)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* @param {Buffer} inviteId
|
|
120
|
-
* @returns {boolean} `true` if an invite existed and was deleted, `false` otherwise
|
|
121
|
-
*/
|
|
122
|
-
deleteByInviteId(inviteId) {
|
|
123
|
-
return this.#byInviteId.delete(inviteId)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* @param {Readonly<Buffer>} projectInviteId
|
|
128
|
-
* @returns {PendingInvite[]} the pending invites that were deleted
|
|
129
|
-
*/
|
|
130
|
-
deleteByProjectInviteId(projectInviteId) {
|
|
131
|
-
/** @type {PendingInvite[]} */
|
|
132
|
-
const result = []
|
|
133
|
-
|
|
134
|
-
for (const pendingInvite of this.invites()) {
|
|
135
|
-
if (pendingInvite.invite.projectInviteId.equals(projectInviteId)) {
|
|
136
|
-
result.push(pendingInvite)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
for (const { invite } of result) this.deleteByInviteId(invite.inviteId)
|
|
141
|
-
|
|
142
|
-
return result
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* @typedef {Object} InviteApiEvents
|
|
148
|
-
* @property {(invite: Invite) => void} invite-received
|
|
149
|
-
* @property {(invite: Invite, removalReason: InviteRemovalReason) => void} invite-removed
|
|
150
|
-
*/
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* @extends {TypedEmitter<InviteApiEvents>}
|
|
154
|
-
*/
|
|
155
|
-
export class InviteApi extends TypedEmitter {
|
|
156
|
-
#getProjectByInviteId
|
|
157
|
-
#addProject
|
|
158
|
-
#pendingInvites = new PendingInvites()
|
|
159
|
-
#l
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* @param {Object} options
|
|
163
|
-
* @param {import('./local-peers.js').LocalPeers} options.rpc
|
|
164
|
-
* @param {object} options.queries
|
|
165
|
-
* @param {(projectInviteId: Readonly<Buffer>) => undefined | { projectPublicId: string }} options.queries.getProjectByInviteId
|
|
166
|
-
* @param {(projectDetails: Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }) => Promise<string>} options.queries.addProject
|
|
167
|
-
* @param {Logger} [options.logger]
|
|
168
|
-
*/
|
|
169
|
-
constructor({ rpc, queries, logger }) {
|
|
170
|
-
super()
|
|
171
|
-
|
|
172
|
-
this.#l = Logger.create('InviteApi', logger)
|
|
173
|
-
|
|
174
|
-
this.rpc = rpc
|
|
175
|
-
this.#getProjectByInviteId = queries.getProjectByInviteId
|
|
176
|
-
this.#addProject = queries.addProject
|
|
177
|
-
|
|
178
|
-
this.rpc.on('invite', (...args) => {
|
|
179
|
-
try {
|
|
180
|
-
this.#handleInviteRpcMessage(...args)
|
|
181
|
-
} catch (err) {
|
|
182
|
-
console.error('Error handling invite', err)
|
|
183
|
-
}
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
this.rpc.on('invite-cancel', (_peerId, inviteCancel) => {
|
|
187
|
-
try {
|
|
188
|
-
this.#handleInviteCancel(inviteCancel)
|
|
189
|
-
} catch (err) {
|
|
190
|
-
console.error('Error handling invite cancel', err)
|
|
191
|
-
}
|
|
192
|
-
})
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* @param {string} peerId
|
|
197
|
-
* @param {InviteRpcMessage} inviteRpcMessage
|
|
198
|
-
*/
|
|
199
|
-
#handleInviteRpcMessage(peerId, inviteRpcMessage) {
|
|
200
|
-
const invite = { ...inviteRpcMessage, receivedAt: Date.now() }
|
|
201
|
-
|
|
202
|
-
this.#l.log('Received invite %h from %S', invite.inviteId, peerId)
|
|
203
|
-
|
|
204
|
-
const isAlreadyMember = Boolean(
|
|
205
|
-
this.#getProjectByInviteId(invite.projectInviteId)
|
|
206
|
-
)
|
|
207
|
-
if (isAlreadyMember) {
|
|
208
|
-
this.#l.log('Invite %h: already in project', invite.inviteId)
|
|
209
|
-
this.rpc
|
|
210
|
-
.sendInviteResponse(peerId, {
|
|
211
|
-
decision: InviteResponse_Decision.ALREADY,
|
|
212
|
-
inviteId: invite.inviteId,
|
|
213
|
-
})
|
|
214
|
-
.catch(noop)
|
|
215
|
-
return
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const hasAlreadyReceivedThisInvite = this.#pendingInvites.hasInviteId(
|
|
219
|
-
invite.inviteId
|
|
220
|
-
)
|
|
221
|
-
if (hasAlreadyReceivedThisInvite) {
|
|
222
|
-
this.#l.log('Invite %h: already received this invite', invite.inviteId)
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
this.#pendingInvites.add({ peerId, invite, isAccepting: false })
|
|
227
|
-
this.emit('invite-received', internalToExternal(invite))
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* @param {InviteCancel} inviteCancel
|
|
232
|
-
*/
|
|
233
|
-
#handleInviteCancel(inviteCancel) {
|
|
234
|
-
const { inviteId } = inviteCancel
|
|
235
|
-
|
|
236
|
-
this.#l.log('Received invite cancel for invite ID %h', inviteId)
|
|
237
|
-
|
|
238
|
-
const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
|
|
239
|
-
if (!pendingInvite) {
|
|
240
|
-
this.#l.log(
|
|
241
|
-
'Received invite cancel for %h but no such invite exists',
|
|
242
|
-
inviteId
|
|
243
|
-
)
|
|
244
|
-
return
|
|
245
|
-
}
|
|
246
|
-
const { invite, isAccepting } = pendingInvite
|
|
247
|
-
|
|
248
|
-
if (isAccepting) {
|
|
249
|
-
this.#l.log(
|
|
250
|
-
"Received invite cancel for %h but we're already accepting",
|
|
251
|
-
inviteId
|
|
252
|
-
)
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
this.#pendingInvites.deleteByInviteId(inviteId)
|
|
257
|
-
this.emit('invite-removed', internalToExternal(invite), 'canceled')
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* @returns {Array<Invite>}
|
|
262
|
-
*/
|
|
263
|
-
getPending() {
|
|
264
|
-
return [...this.#pendingInvites.invites()].map(({ invite }) =>
|
|
265
|
-
internalToExternal(invite)
|
|
266
|
-
)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Attempt to accept the invite.
|
|
271
|
-
*
|
|
272
|
-
* This can fail if the invitor has canceled the invite or if you cannot
|
|
273
|
-
* connect to the invitor's device.
|
|
274
|
-
*
|
|
275
|
-
* If the invite is accepted and you had other invites to the same project,
|
|
276
|
-
* those invites are removed, and the invitors are told that you're already
|
|
277
|
-
* part of this project.
|
|
278
|
-
*
|
|
279
|
-
* @param {Pick<Invite, 'inviteId'>} invite
|
|
280
|
-
* @returns {Promise<string>}
|
|
281
|
-
*/
|
|
282
|
-
async accept({ inviteId: inviteIdString }) {
|
|
283
|
-
const inviteId = Buffer.from(inviteIdString, 'hex')
|
|
284
|
-
|
|
285
|
-
const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
|
|
286
|
-
if (!pendingInvite) {
|
|
287
|
-
throw new Error(`Cannot find invite ID ${inviteIdString}`)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const { peerId, invite } = pendingInvite
|
|
291
|
-
const { projectName, projectInviteId } = invite
|
|
292
|
-
|
|
293
|
-
/** @param {InviteRemovalReason} removalReason */
|
|
294
|
-
const removePendingInvite = (removalReason) => {
|
|
295
|
-
const didDelete = this.#pendingInvites.deleteByInviteId(inviteId)
|
|
296
|
-
if (didDelete) {
|
|
297
|
-
this.emit('invite-removed', internalToExternal(invite), removalReason)
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// This is probably impossible in the UI, but it's theoretically possible
|
|
302
|
-
// to join a project while an invite is pending, so we need to check this.
|
|
303
|
-
const existingProject = this.#getProjectByInviteId(projectInviteId)
|
|
304
|
-
if (existingProject) {
|
|
305
|
-
this.#l.log(
|
|
306
|
-
"Went to accept invite %h but we're already in the project",
|
|
307
|
-
inviteId
|
|
308
|
-
)
|
|
309
|
-
const pendingInvitesDeleted =
|
|
310
|
-
this.#pendingInvites.deleteByProjectInviteId(projectInviteId)
|
|
311
|
-
for (const pendingInvite of pendingInvitesDeleted) {
|
|
312
|
-
this.rpc
|
|
313
|
-
.sendInviteResponse(pendingInvite.peerId, {
|
|
314
|
-
decision: InviteResponse_Decision.ALREADY,
|
|
315
|
-
inviteId: pendingInvite.invite.inviteId,
|
|
316
|
-
})
|
|
317
|
-
.catch(noop)
|
|
318
|
-
this.emit(
|
|
319
|
-
'invite-removed',
|
|
320
|
-
internalToExternal(pendingInvite.invite),
|
|
321
|
-
'accepted'
|
|
322
|
-
)
|
|
323
|
-
}
|
|
324
|
-
return existingProject.projectPublicId
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
assert(
|
|
328
|
-
!this.#pendingInvites.isAcceptingForProject(projectInviteId),
|
|
329
|
-
`Cannot double-accept invite for project ${projectInviteId
|
|
330
|
-
.toString('hex')
|
|
331
|
-
.slice(0, 7)}`
|
|
332
|
-
)
|
|
333
|
-
this.#pendingInvites.markAccepting(inviteId)
|
|
334
|
-
|
|
335
|
-
const projectDetailsAbortController = new AbortController()
|
|
336
|
-
|
|
337
|
-
const projectDetailsPromise =
|
|
338
|
-
/** @type {typeof pEvent<'got-project-details', [string, ProjectJoinDetails]>} */ (
|
|
339
|
-
pEvent
|
|
340
|
-
)(this.rpc, 'got-project-details', {
|
|
341
|
-
multiArgs: true,
|
|
342
|
-
filter: ([projectDetailsPeerId, details]) =>
|
|
343
|
-
// This peer ID check is probably superfluous because the invite ID
|
|
344
|
-
// should be unguessable, but might be useful if someone forwards an
|
|
345
|
-
// invite message (or if there's an unforeseen bug).
|
|
346
|
-
timingSafeEqual(projectDetailsPeerId, peerId) &&
|
|
347
|
-
timingSafeEqual(inviteId, details.inviteId),
|
|
348
|
-
signal: projectDetailsAbortController.signal,
|
|
349
|
-
})
|
|
350
|
-
.then((args) => args?.[1])
|
|
351
|
-
.catch(noop)
|
|
352
|
-
|
|
353
|
-
this.#l.log('Sending accept response for invite %h', inviteId)
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
await this.rpc.sendInviteResponse(peerId, {
|
|
357
|
-
decision: InviteResponse_Decision.ACCEPT,
|
|
358
|
-
inviteId,
|
|
359
|
-
})
|
|
360
|
-
} catch (e) {
|
|
361
|
-
projectDetailsAbortController.abort()
|
|
362
|
-
removePendingInvite('connection error')
|
|
363
|
-
throw new Error('Could not accept invite: Peer disconnected')
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** @type {string} */ let projectPublicId
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
const details = await projectDetailsPromise
|
|
370
|
-
assert(details, 'Expected project details')
|
|
371
|
-
projectPublicId = await this.#addProject({ ...details, projectName })
|
|
372
|
-
} catch (e) {
|
|
373
|
-
removePendingInvite('internal error')
|
|
374
|
-
throw new Error('Failed to join project')
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const pendingInvitesDeleted =
|
|
378
|
-
this.#pendingInvites.deleteByProjectInviteId(projectInviteId)
|
|
379
|
-
|
|
380
|
-
for (const pendingInvite of pendingInvitesDeleted) {
|
|
381
|
-
const isPendingInviteWeJustAccepted =
|
|
382
|
-
// Unlike the above, these don't need to be timing-safe, because
|
|
383
|
-
// it's unlikely this method is vulnerable to timing attacks.
|
|
384
|
-
peerId === pendingInvite.peerId &&
|
|
385
|
-
inviteId.equals(pendingInvite.invite.inviteId)
|
|
386
|
-
if (isPendingInviteWeJustAccepted) continue
|
|
387
|
-
|
|
388
|
-
this.#l.log(
|
|
389
|
-
'Sending "already" response for invite %h to %S',
|
|
390
|
-
inviteId,
|
|
391
|
-
pendingInvite.peerId
|
|
392
|
-
)
|
|
393
|
-
|
|
394
|
-
this.rpc
|
|
395
|
-
.sendInviteResponse(pendingInvite.peerId, {
|
|
396
|
-
decision: InviteResponse_Decision.ALREADY,
|
|
397
|
-
inviteId: pendingInvite.invite.inviteId,
|
|
398
|
-
})
|
|
399
|
-
.catch(noop)
|
|
400
|
-
this.emit(
|
|
401
|
-
'invite-removed',
|
|
402
|
-
internalToExternal(pendingInvite.invite),
|
|
403
|
-
'accepted other'
|
|
404
|
-
)
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
this.emit('invite-removed', internalToExternal(invite), 'accepted')
|
|
408
|
-
|
|
409
|
-
return projectPublicId
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* @param {Pick<Invite, 'inviteId'>} invite
|
|
414
|
-
* @returns {void}
|
|
415
|
-
*/
|
|
416
|
-
reject({ inviteId: inviteIdString }) {
|
|
417
|
-
const inviteId = Buffer.from(inviteIdString, 'hex')
|
|
418
|
-
|
|
419
|
-
const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
|
|
420
|
-
assert(!!pendingInvite, `Cannot find invite ${inviteId}`)
|
|
421
|
-
|
|
422
|
-
const { peerId, invite, isAccepting } = pendingInvite
|
|
423
|
-
|
|
424
|
-
assert(!isAccepting, `Cannot reject ${inviteIdString}`)
|
|
425
|
-
|
|
426
|
-
this.#l.log('Rejecting invite %h', inviteId)
|
|
427
|
-
|
|
428
|
-
this.rpc
|
|
429
|
-
.sendInviteResponse(peerId, {
|
|
430
|
-
decision: InviteResponse_Decision.REJECT,
|
|
431
|
-
inviteId: invite.inviteId,
|
|
432
|
-
})
|
|
433
|
-
.catch(noop)
|
|
434
|
-
|
|
435
|
-
this.#pendingInvites.deleteByInviteId(inviteId)
|
|
436
|
-
this.emit('invite-removed', internalToExternal(invite), 'rejected')
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* @param {InviteInternal} internal
|
|
442
|
-
* @returns {Invite}
|
|
443
|
-
*/
|
|
444
|
-
function internalToExternal(internal) {
|
|
445
|
-
return {
|
|
446
|
-
...internal,
|
|
447
|
-
inviteId: internal.inviteId.toString('hex'),
|
|
448
|
-
projectInviteId: internal.projectInviteId.toString('hex'),
|
|
449
|
-
}
|
|
450
|
-
}
|