@mblaney/holster 1.0.22 → 1.0.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/get.js CHANGED
@@ -16,6 +16,7 @@ const Get = (lex, graph) => {
16
16
  if (!graph[soul]) return
17
17
 
18
18
  const node = {_: {"#": soul, ">": {}}}
19
+ let signatures = {}
19
20
 
20
21
  if (typeof lex["."] === "string") {
21
22
  const key = lex["."]
@@ -23,14 +24,23 @@ const Get = (lex, graph) => {
23
24
 
24
25
  node[key] = graph[soul][key]
25
26
  node._[">"][key] = graph[soul]._[">"][key]
27
+ if (graph[soul]._["s"] && graph[soul]._["s"][key]) {
28
+ signatures[key] = graph[soul]._["s"][key]
29
+ }
26
30
  } else {
27
31
  for (const key of Object.keys(graph[soul])) {
28
32
  if (match(lex["."], key)) {
29
33
  node[key] = graph[soul][key]
30
34
  node._[">"][key] = graph[soul]._[">"][key]
35
+ if (graph[soul]._["s"] && graph[soul]._["s"][key]) {
36
+ signatures[key] = graph[soul]._["s"][key]
37
+ }
31
38
  }
32
39
  }
33
40
  }
41
+ if (Object.keys(signatures).length > 0) {
42
+ node._["s"] = signatures
43
+ }
34
44
  return {[soul]: node}
35
45
  }
36
46
 
package/src/ham.js CHANGED
@@ -43,6 +43,7 @@ Ham.mix = async (change, graph, secure, listen) => {
43
43
  const machine = Date.now()
44
44
  const now = {}
45
45
  const defer = {}
46
+ const validProperties = new Map() // Track valid properties per soul
46
47
  let wait = 0
47
48
 
48
49
  for (const soul of Object.keys(change)) {
@@ -54,10 +55,9 @@ Ham.mix = async (change, graph, secure, listen) => {
54
55
 
55
56
  if (!node || !node._) continue
56
57
 
57
- const sig = node[utils.userSignature]
58
58
  const pub = node[utils.userPublicKey]
59
- // If a signature and public key are provided then always verify.
60
- if (sig && pub) verify = true
59
+ // If per-property signatures and public key are provided then always verify.
60
+ if (node._["s"] && pub) verify = true
61
61
 
62
62
  // Special case if soul starts with "~". Node must be system data ie,
63
63
  // ~@alias or ~publickey. For aliases, key and value must be a self
@@ -77,17 +77,35 @@ Ham.mix = async (change, graph, secure, listen) => {
77
77
  }
78
78
  }
79
79
  if (verify) {
80
- // Partial nodes can be read from disk, which are ignored. This could be
81
- // a missing signature or public key, or missing properties on the node
82
- // which means verify won't work either.
83
- if (!sig || !pub || !(await SEA.verify({m: node, s: sig}, {pub: pub}))) {
80
+ // Per-property signatures stored in node._["s"]
81
+ if (!pub || !node._) {
84
82
  continue
85
83
  }
84
+
85
+ // Verify properties individually, returns array of valid property names
86
+ const validProps = await SEA.verifyProperties(node, pub)
87
+
88
+ // If no properties verified, skip this update
89
+ if (validProps.length === 0) {
90
+ continue
91
+ }
92
+
93
+ // Track valid properties for this soul
94
+ validProperties.set(soul, new Set(validProps))
86
95
  }
87
96
 
88
97
  for (const key of Object.keys(node)) {
89
98
  if (key === "_") continue
90
99
 
100
+ // Skip metadata fields (including userSignature for old data)
101
+ if (key === utils.userSignature || key === utils.userPublicKey) continue
102
+
103
+ // If this soul had verification, only process properties that have valid signatures
104
+ // Properties without signatures in this update are left unchanged
105
+ if (validProperties.has(soul) && !validProperties.get(soul).has(key)) {
106
+ continue
107
+ }
108
+
91
109
  const value = node[key]
92
110
  const state = node._ && node._[">"] ? node._[">"][key] : 0
93
111
  const currentValue = (graph[soul] || {})[key]
@@ -107,21 +125,27 @@ Ham.mix = async (change, graph, secure, listen) => {
107
125
  // Wait the shortest difference before trying the updates again.
108
126
  if (wait === 0 || skew < wait) wait = nodeWait = skew
109
127
  if (!defer[soul]) {
110
- defer[soul] = {_: {"#": soul, ">": {}}}
128
+ defer[soul] = {_: {"#": soul, ">": {}, s: {}}}
111
129
  }
112
130
  defer[soul][key] = value
113
131
  defer[soul]._[">"][key] = state
132
+ if (node._["s"] && node._["s"][key]) {
133
+ defer[soul]._["s"][key] = node._["s"][key]
134
+ }
114
135
  } else {
115
136
  const result = Ham(state, currentState, value, currentValue)
116
137
  if (result.incoming) {
117
138
  if (!now[soul]) {
118
- now[soul] = {_: {"#": soul, ">": {}}}
139
+ now[soul] = {_: {"#": soul, ">": {}, s: {}}}
119
140
  }
120
141
  if (!graph[soul]) {
121
- graph[soul] = {_: {"#": soul, ">": {}}}
142
+ graph[soul] = {_: {"#": soul, ">": {}, s: {}}}
122
143
  }
123
144
  graph[soul][key] = now[soul][key] = value
124
145
  graph[soul]._[">"][key] = now[soul]._[">"][key] = state
146
+ if (node._["s"] && node._["s"][key]) {
147
+ graph[soul]._["s"][key] = now[soul]._["s"][key] = node._["s"][key]
148
+ }
125
149
  // Call event listeners for update on key, mix is called before
126
150
  // put has finished so wait for what could be multiple nested
127
151
  // updates on a node.
package/src/holster.js CHANGED
@@ -97,8 +97,9 @@ const Holster = opt => {
97
97
 
98
98
  const graph = async (soul, data, userctx, cb) => {
99
99
  if (userctx) {
100
- const signed = await SEA.sign(data, userctx)
101
- return utils.graph(soul, signed.m, signed.s, userctx.pub)
100
+ // Sign each property individually
101
+ const propertySignatures = await SEA.signProperties(data, userctx)
102
+ return utils.graph(soul, data, propertySignatures, userctx.pub)
102
103
  }
103
104
 
104
105
  if (opt.secure) {
@@ -151,12 +152,8 @@ const Holster = opt => {
151
152
  // Found a soul that needs resolving, need the previous context
152
153
  // (ie the parent node) to find a soul relation for it.
153
154
  const {item, soul} = ctx.chain[i - 1]
154
- // If this is a user context or using secure mode then need to get the
155
- // whole node for verification.
156
- const lex =
157
- ctx.user || opt.secure ? {"#": soul} : {"#": soul, ".": item}
158
155
  wire.get(
159
- lex,
156
+ {"#": soul, ".": item},
160
157
  async msg => {
161
158
  if (msg.err) {
162
159
  if (ctx.user || opt.secure) {
@@ -384,10 +381,7 @@ const Holster = opt => {
384
381
  // When result is true data is a property to put on the current soul.
385
382
  // Need to check if item is a rel and also set the node to null. (This
386
383
  // applies for any update from a rel to a property, not just null.)
387
- // If using secure mode need to get the whole node for verification.
388
- const lex =
389
- ctx.user || opt.secure ? {"#": soul} : {"#": soul, ".": item}
390
- wire.get(lex, async msg => {
384
+ wire.get({"#": soul, ".": item}, async msg => {
391
385
  if (msg.err) {
392
386
  _ack(`error getting ${soul}: ${msg.err}`)
393
387
  return
@@ -452,10 +446,7 @@ const Holster = opt => {
452
446
 
453
447
  // Otherwise put the data using the keys returned in result.
454
448
  // Need to check if a rel has already been added on the current node.
455
- // If using secure mode need to get the whole node for verification.
456
- const lex =
457
- ctx.user || opt.secure ? {"#": soul} : {"#": soul, ".": item}
458
- wire.get(lex, async msg => {
449
+ wire.get({"#": soul, ".": item}, async msg => {
459
450
  if (msg.err) {
460
451
  _ack(`error getting ${soul}.${item}: ${msg.err}`)
461
452
  return
package/src/radisk.js CHANGED
@@ -461,10 +461,14 @@ const Radisk = opt => {
461
461
 
462
462
  Radisk.encode = data => {
463
463
  // A key should be passed in as a string to encode, a value can optionally be
464
- // an array of 2 items to include the value's state, as is done by store.js.
464
+ // an array of 2-3 items: [value, state] or [value, state, signature].
465
465
  let state = ""
466
- if (data instanceof Array && data.length === 2) {
466
+ let sig = ""
467
+ if (data instanceof Array && (data.length === 2 || data.length === 3)) {
467
468
  state = etx + data[1]
469
+ if (data.length === 3 && data[2]) {
470
+ sig = etx + data[2]
471
+ }
468
472
  data = data[0]
469
473
  }
470
474
 
@@ -475,19 +479,19 @@ Radisk.encode = data => {
475
479
  while ((current = data[i++])) {
476
480
  if (current === unit) text += unit
477
481
  }
478
- return text + '"' + data + state + unit
482
+ return text + '"' + data + state + sig + unit
479
483
  }
480
484
 
481
485
  const rel = utils.rel.is(data)
482
- if (rel) return unit + "#" + rel + state + unit
486
+ if (rel) return unit + "#" + rel + state + sig + unit
483
487
 
484
- if (utils.num.is(data)) return unit + "+" + (data || 0) + state + unit
488
+ if (utils.num.is(data)) return unit + "+" + (data || 0) + state + sig + unit
485
489
 
486
- if (data === true) return unit + "+" + state + unit
490
+ if (data === true) return unit + "+" + state + sig + unit
487
491
 
488
- if (data === false) return unit + "-" + state + unit
492
+ if (data === false) return unit + "-" + state + sig + unit
489
493
 
490
- if (data === null) return unit + " " + state + unit
494
+ if (data === null) return unit + " " + state + sig + unit
491
495
  }
492
496
 
493
497
  Radisk.decode = (data, obj) => {
@@ -522,7 +526,11 @@ Radisk.decode = (data, obj) => {
522
526
 
523
527
  if (obj) obj.i = i + 1
524
528
 
525
- let [value, state] = text.split(etx)
529
+ let parts = text.split(etx)
530
+ let value = parts[0]
531
+ let state = parts[1]
532
+ let sig = parts[2]
533
+
526
534
  if (!state) {
527
535
  if (previous === '"') return text
528
536
 
@@ -539,20 +547,36 @@ Radisk.decode = (data, obj) => {
539
547
  if (previous === " ") return null
540
548
  } else {
541
549
  state = parseFloat(state)
542
- // If state was found then return an array.
543
- if (previous === '"') return [value, state]
550
+ // If state was found then return an array with [value, state] or [value, state, sig]
551
+ if (sig) {
552
+ if (previous === '"') return [value, state, sig]
544
553
 
545
- if (previous === "#") return [utils.rel.ify(value), state]
554
+ if (previous === "#") return [utils.rel.ify(value), state, sig]
546
555
 
547
- if (previous === "+") {
548
- if (value.length === 0) return [true, state]
556
+ if (previous === "+") {
557
+ if (value.length === 0) return [true, state, sig]
549
558
 
550
- return [parseFloat(value), state]
551
- }
559
+ return [parseFloat(value), state, sig]
560
+ }
561
+
562
+ if (previous === "-") return [false, state, sig]
552
563
 
553
- if (previous === "-") return [false, state]
564
+ if (previous === " ") return [null, state, sig]
565
+ } else {
566
+ if (previous === '"') return [value, state]
554
567
 
555
- if (previous === " ") return [null, state]
568
+ if (previous === "#") return [utils.rel.ify(value), state]
569
+
570
+ if (previous === "+") {
571
+ if (value.length === 0) return [true, state]
572
+
573
+ return [parseFloat(value), state]
574
+ }
575
+
576
+ if (previous === "-") return [false, state]
577
+
578
+ if (previous === " ") return [null, state]
579
+ }
556
580
  }
557
581
  }
558
582
 
package/src/sea.js CHANGED
@@ -118,7 +118,7 @@ const SEA = {
118
118
  // Allow data to be passed in with graph meta data,
119
119
  // (which should not be part of signature, so not verified).
120
120
  for (const k of Object.keys(signed.m).sort()) {
121
- if (k !== "_" && k != userPublicKey && k != userSignature) {
121
+ if (k !== "_" && k !== userPublicKey && k !== userSignature) {
122
122
  msg[k] = signed.m[k]
123
123
  }
124
124
  }
@@ -135,6 +135,59 @@ const SEA = {
135
135
  if (cb) cb(null)
136
136
  return null
137
137
  },
138
+ // Verify individual property signatures stored in node._["s"]
139
+ // Returns array of valid property names
140
+ verifyProperties: async (node, pub, cb) => {
141
+ if (!pub || !node) {
142
+ if (cb) cb([])
143
+ return []
144
+ }
145
+
146
+ const key = await utils.subtle.importKey(
147
+ "jwk",
148
+ utils.jwk(pub),
149
+ {name: "ECDSA", namedCurve: "P-256"},
150
+ false,
151
+ ["verify"],
152
+ )
153
+
154
+ const alg = {name: "ECDSA", hash: {name: "SHA-256"}}
155
+ const valid = []
156
+ const propertySignatures = (node._ && node._["s"]) || {}
157
+
158
+ // Verify each property individually
159
+ for (const k of Object.keys(node).sort()) {
160
+ if (k === "_" || k == userPublicKey) continue
161
+
162
+ const propSig = propertySignatures[k]
163
+ if (!propSig) {
164
+ console.log(`warning: property '${k}' missing signature`)
165
+ continue
166
+ }
167
+
168
+ try {
169
+ const hash = await utils.sha256(node[k])
170
+ const sig = new Uint8Array(SafeBuffer.from(propSig, "base64"))
171
+
172
+ const isValid = await utils.subtle.verify(
173
+ alg,
174
+ key,
175
+ sig,
176
+ new Uint8Array(hash),
177
+ )
178
+ if (isValid) {
179
+ valid.push(k)
180
+ } else {
181
+ console.log(`warning: property '${k}' signature verification failed`)
182
+ }
183
+ } catch (err) {
184
+ console.log(`warning: property '${k}' error verifying: ${err.message}`)
185
+ }
186
+ }
187
+
188
+ if (cb) cb(valid)
189
+ return valid
190
+ },
138
191
  sign: async (data, pair, cb) => {
139
192
  if (!pair || !pair.pub || !pair.priv) {
140
193
  if (cb) cb(null)
@@ -147,7 +200,7 @@ const SEA = {
147
200
  } else {
148
201
  const check = utils.parse(data)
149
202
  for (const k of Object.keys(check).sort()) {
150
- if (k !== "_" && k != userPublicKey && k != userSignature) {
203
+ if (k !== "_" && k !== userPublicKey && k !== userSignature) {
151
204
  msg[k] = check[k]
152
205
  }
153
206
  }
@@ -172,6 +225,37 @@ const SEA = {
172
225
  if (cb) cb(signed)
173
226
  return signed
174
227
  },
228
+ // Sign individual properties for per-property verification
229
+ signProperties: async (data, pair, cb) => {
230
+ if (!pair || !pair.pub || !pair.priv) {
231
+ if (cb) cb(null)
232
+ return null
233
+ }
234
+
235
+ const check = utils.parse(data)
236
+ const propertySignatures = {}
237
+ const jwk = utils.jwk(pair.pub, pair.priv)
238
+ const alg = {name: "ECDSA", namedCurve: "P-256"}
239
+ const key = await utils.subtle.importKey("jwk", jwk, alg, false, ["sign"])
240
+
241
+ // Sign each property individually
242
+ for (const k of Object.keys(check).sort()) {
243
+ if (k !== "_" && k != userPublicKey && k != userSignature) {
244
+ const hash = await utils.sha256(check[k])
245
+ const sig = await utils.subtle.sign(
246
+ {name: "ECDSA", hash: {name: "SHA-256"}},
247
+ key,
248
+ new Uint8Array(hash),
249
+ )
250
+ propertySignatures[k] = SafeBuffer.from(sig, "binary").toString(
251
+ "base64",
252
+ )
253
+ }
254
+ }
255
+
256
+ if (cb) cb(propertySignatures)
257
+ return propertySignatures
258
+ },
175
259
  work: async (data, salt, cb) => {
176
260
  if (typeof salt === "function") {
177
261
  cb = salt
package/src/store.js CHANGED
@@ -224,12 +224,17 @@ const Store = opt => {
224
224
  var soul = lex["#"]
225
225
  var key = typeof lex["."] === "string" ? lex["."] : ""
226
226
  var node
227
+ var signatures = {}
227
228
  const each = (value, key) => {
228
229
  if (!utils.match(lex["."], key)) return
229
230
 
230
231
  if (!node) node = {_: {"#": soul, ">": {}}}
231
232
  node[key] = value[0]
232
233
  node._[">"][key] = value[1]
234
+ // If signature is present, store it in _["s"]
235
+ if (value.length === 3 && value[2]) {
236
+ signatures[key] = value[2]
237
+ }
233
238
  }
234
239
 
235
240
  radisk(soul + enq + key, (err, value) => {
@@ -242,6 +247,10 @@ const Store = opt => {
242
247
  each(value, key)
243
248
  graph = {[soul]: node}
244
249
  }
250
+ // Only add _["s"] if we found any signatures
251
+ if (graph && graph[soul] && Object.keys(signatures).length > 0) {
252
+ graph[soul]._["s"] = signatures
253
+ }
245
254
  cb(err, graph)
246
255
  })
247
256
  },
@@ -277,7 +286,10 @@ const Store = opt => {
277
286
  count++
278
287
  let value = node[key]
279
288
  let state = node._[">"][key]
280
- radisk(soul + enq + key, [value, state], ack)
289
+ let sig = node._["s"] && node._["s"][key]
290
+ // Only pass signature if it exists, otherwise just [value, state]
291
+ let data = sig ? [value, state, sig] : [value, state]
292
+ radisk(soul + enq + key, data, ack)
281
293
  })
282
294
  })
283
295
  },
package/src/user.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as utils from "./utils.js"
2
2
  import Wire from "./wire.js"
3
3
  import SEA from "./sea.js"
4
+ import {userPublicKey, userSignature} from "./utils.js"
4
5
 
5
6
  const User = (opt, wire) => {
6
7
  if (!wire) wire = Wire(opt)
@@ -55,15 +56,12 @@ const User = (opt, wire) => {
55
56
  const salt = utils.text.random(64)
56
57
  const work = await SEA.work(newPassword, salt)
57
58
  const enc = await SEA.encrypt(dec, work)
58
- // Put all fields so that verification applies to the whole node.
59
+ // Only update the auth property with the new password encryption.
59
60
  const update = {
60
- username: username,
61
- pub: data.pub,
62
- epub: data.epub,
63
61
  auth: JSON.stringify({enc: enc, salt: salt}),
64
62
  }
65
- const signed = await SEA.sign(update, user.is)
66
- const graph = utils.graph(pub, signed.m, signed.s, data.pub)
63
+ const propertySignatures = await SEA.signProperties(update, user.is)
64
+ const graph = utils.graph(pub, update, propertySignatures, data.pub)
67
65
  wire.put(graph, err => {
68
66
  if (err) {
69
67
  done(`error putting ${update} on ${pub}: ${err}`)
@@ -74,9 +72,38 @@ const User = (opt, wire) => {
74
72
  return
75
73
  }
76
74
 
77
- return done(null)
75
+ // TODO: Remove this migration once all peers have updated to the new
76
+ // version and have received per-property signatures. This temporarily
77
+ // broadcasts signed data on every login to ensure new peers can
78
+ // persist it.
79
+ const migrationData = {}
80
+ for (const key of Object.keys(data)) {
81
+ if (key !== "_" && key !== userPublicKey && key !== userSignature) {
82
+ migrationData[key] = data[key]
83
+ }
84
+ }
85
+ // Sign each property individually
86
+ const propertySignatures = await SEA.signProperties(
87
+ migrationData,
88
+ user.is,
89
+ )
90
+ const graph = utils.graph(
91
+ pub,
92
+ migrationData,
93
+ propertySignatures,
94
+ data.pub,
95
+ )
96
+ wire.put(graph, err => {
97
+ if (err) {
98
+ // Log migration error but don't fail authentication
99
+ console.log(
100
+ `warning: failed to migrate signatures on login: ${err}`,
101
+ )
102
+ }
103
+ done(null)
104
+ })
78
105
  },
79
- {wait: 1000},
106
+ {wait: 5000},
80
107
  )
81
108
  }
82
109
 
@@ -103,7 +130,7 @@ const User = (opt, wire) => {
103
130
  pubs = Object.keys(data)
104
131
  next()
105
132
  },
106
- {wait: 1000},
133
+ {wait: 5000},
107
134
  )
108
135
  }
109
136
 
@@ -160,8 +187,9 @@ const User = (opt, wire) => {
160
187
  }
161
188
 
162
189
  const pub = "~" + pair.pub
163
- const signed = await SEA.sign(data, pair)
164
- const graph = utils.graph(pub, signed.m, signed.s, pair.pub)
190
+ // Sign each property individually for new account
191
+ const propertySignatures = await SEA.signProperties(data, pair)
192
+ const graph = utils.graph(pub, data, propertySignatures, pair.pub)
165
193
  wire.put(graph, err => {
166
194
  creating = false
167
195
  if (err) {
@@ -181,7 +209,7 @@ const User = (opt, wire) => {
181
209
  })
182
210
  })
183
211
  },
184
- {wait: 1000},
212
+ {wait: 5000},
185
213
  )
186
214
  },
187
215
  auth: (username, password, cb) => {
@@ -318,9 +346,10 @@ const User = (opt, wire) => {
318
346
  }
319
347
 
320
348
  const data = {username: null, pub: null, epub: null, auth: null}
321
- const signed = await SEA.sign(data, user.is)
349
+ // Sign each property individually for account deletion
350
+ const propertySignatures = await SEA.signProperties(data, user.is)
322
351
  const pub = "~" + user.is.pub
323
- const graph = utils.graph(pub, signed.m, signed.s, user.is.pub)
352
+ const graph = utils.graph(pub, data, propertySignatures, user.is.pub)
324
353
  wire.put(graph, err => {
325
354
  if (err) {
326
355
  ack(`error putting null on ${pub}: ${err}`)
package/src/utils.js CHANGED
@@ -90,13 +90,13 @@ export const graph = (soul, data, sig, pub) => {
90
90
  g[soul]._[">"][key] = timestamp
91
91
  }
92
92
  }
93
- // If a signature and public key are provided they also need to be stored on
94
- // the node to ensure that future updates are only possible with the same
95
- // public key. The signature is requried because later get requests will
96
- // broadcast the data as a put, which other devices will need to verify.
93
+ // Store per-property signatures in node._["s"]
94
+ // sig is an object with per-property signatures
97
95
  if (sig && pub) {
98
- g[soul][userSignature] = sig
99
- g[soul]._[">"][userSignature] = timestamp
96
+ g[soul]._["s"] = {}
97
+ for (const [key, signature] of Object.entries(sig)) {
98
+ g[soul]._["s"][key] = signature
99
+ }
100
100
  g[soul][userPublicKey] = pub
101
101
  g[soul]._[">"][userPublicKey] = timestamp
102
102
  }
@@ -16,16 +16,47 @@ describe("holster.on", () => {
16
16
  })
17
17
 
18
18
  test("on for property on root then update - event", (t, done) => {
19
- // The property needs to exist before it can be listend to for updates.
19
+ // Listener doesn't get called for initial put.
20
+ const callback = data => {
21
+ assert.equal(data, "update")
22
+ holster.get("key").off(callback)
23
+ done()
24
+ }
25
+ holster.get("key").on(callback)
26
+
20
27
  holster.get("key").put("value", err => {
21
28
  assert.equal(err, null)
22
29
 
23
- holster.get("key").on(data => {
24
- assert.equal(data, "update")
25
- done()
30
+ holster.get("key").put("update", err => {
31
+ assert.equal(err, null)
26
32
  })
33
+ })
34
+ })
27
35
 
28
- holster.get("key").put("update", err => {
36
+ test("on for property then update - two listeners", (t, done) => {
37
+ let done1 = false
38
+ let done2 = false
39
+
40
+ const callback1 = data => {
41
+ assert.equal(data, "update")
42
+ holster.get("two").off(callback1)
43
+ if (done2) done()
44
+ else done1 = true
45
+ }
46
+ holster.get("two").on(callback1)
47
+
48
+ const callback2 = data => {
49
+ assert.equal(data, "update")
50
+ holster.get("two").off(callback2)
51
+ if (done1) done()
52
+ else done2 = true
53
+ }
54
+ holster.get("two").on(callback2)
55
+
56
+ holster.get("two").put("value", err => {
57
+ assert.equal(err, null)
58
+
59
+ holster.get("two").put("update", err => {
29
60
  assert.equal(err, null)
30
61
  })
31
62
  })