@mblaney/holster 0.1.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.
Files changed (46) hide show
  1. package/.github/workflows/node.js.yml +31 -0
  2. package/LICENSE.md +55 -0
  3. package/README.md +24 -0
  4. package/build/holster.js +4 -0
  5. package/build/holster.js.map +7 -0
  6. package/examples/index.html +33 -0
  7. package/examples/index.js +49 -0
  8. package/examples/user.html +31 -0
  9. package/examples/user.js +88 -0
  10. package/examples/wire.html +27 -0
  11. package/examples/wire.js +55 -0
  12. package/package.json +44 -0
  13. package/src/array.js +34 -0
  14. package/src/buffer.js +83 -0
  15. package/src/dup.js +23 -0
  16. package/src/get.js +19 -0
  17. package/src/ham.js +151 -0
  18. package/src/holster.js +502 -0
  19. package/src/index.js +21 -0
  20. package/src/radisk.js +438 -0
  21. package/src/radix.js +106 -0
  22. package/src/sea-utils.js +65 -0
  23. package/src/sea.js +224 -0
  24. package/src/store.js +225 -0
  25. package/src/user.js +330 -0
  26. package/src/utils.js +124 -0
  27. package/src/wire.js +290 -0
  28. package/test/holster.get.test.js +60 -0
  29. package/test/holster.lex.test.js +196 -0
  30. package/test/holster.off.test.js +53 -0
  31. package/test/holster.on.test.js +168 -0
  32. package/test/holster.put.test.js +278 -0
  33. package/test/holster.secure.test.js +210 -0
  34. package/test/holster.user.get.test.js +86 -0
  35. package/test/holster.user.lex.test.js +148 -0
  36. package/test/holster.user.off.test.js +72 -0
  37. package/test/holster.user.on.test.js +185 -0
  38. package/test/holster.user.put.test.js +381 -0
  39. package/test/kitty/! +9 -0
  40. package/test/names.js +225 -0
  41. package/test/radisk.test.js +191 -0
  42. package/test/radix.test.js +117 -0
  43. package/test/sea.test.js +284 -0
  44. package/test/store.test.js +316 -0
  45. package/test/user.test.js +173 -0
  46. package/test/wire.test.js +159 -0
@@ -0,0 +1,31 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Holster.user - test examples</title>
6
+ </head>
7
+ <body>
8
+ <h2>Holster.user - test examples</h2>
9
+ <p>
10
+ This file loads <code>user.js</code> which contains examples that test the
11
+ Holster.user API. See the console log for test output.
12
+ </p>
13
+ <p>
14
+ The tests create two users, Alice and Bob. They both write data to their
15
+ own user space. Bob writes secret data that can only be read by Alice,
16
+ then Alice logs in again to read the message.
17
+ </p>
18
+ <p>
19
+ These tests also show how to use the SEA API (Security, Encryption, and
20
+ Authorization), which can be loaded on it's own, but it made available via
21
+ holster.SEA. The holster.user API uses the SEA API internally for creating
22
+ and authenticating users.
23
+ </p>
24
+ <p>
25
+ The <code>user.js</code> file is loaded as a module, which allows it to
26
+ import the development build of Holster. You can switch to the production
27
+ build by changing the import path at the top of the file.
28
+ </p>
29
+ <script type="module" src="./user.js"></script>
30
+ </body>
31
+ </html>
@@ -0,0 +1,88 @@
1
+ // Switch src to build for the production version.
2
+ import Holster from "../src/holster.js"
3
+
4
+ const holster = Holster({indexedDB: true})
5
+ globalThis.holster = holster
6
+
7
+ const user = holster.user()
8
+
9
+ let alice, bob
10
+ // Wait for the websocket to connect.
11
+ setTimeout(() => {
12
+ user.create("alice", "password", err => {
13
+ if (err !== "Username already exists") {
14
+ console.log(err)
15
+ return
16
+ }
17
+
18
+ user.auth("alice", "password", err => {
19
+ if (err) {
20
+ console.log(err)
21
+ return
22
+ }
23
+
24
+ // Store Alice's encryption key so Bob can use it to create shared data.
25
+ alice = {epub: user.is.epub}
26
+ console.log("Alice logged in.", user.is)
27
+
28
+ const data = "Success! Only Alice can put data on their account"
29
+ user.get("protected").put(data, err => {
30
+ if (err) {
31
+ console.log(err)
32
+ return
33
+ }
34
+
35
+ user.get("protected", console.log)
36
+ })
37
+ })
38
+ })
39
+ }, 1000)
40
+
41
+ setTimeout(() => {
42
+ user.create("bob", "stronger password", err => {
43
+ if (err !== "Username already exists") {
44
+ console.log(err)
45
+ return
46
+ }
47
+
48
+ user.auth("bob", "stronger password", async err => {
49
+ if (err) {
50
+ console.log(err)
51
+ return
52
+ }
53
+
54
+ // Store Bob's public keys so Alice can use them to read shared data.
55
+ bob = {pub: user.is.pub, epub: user.is.epub}
56
+ console.log("Bob logged in.", user.is)
57
+
58
+ // For this test let's create shared private data for Alice.
59
+ const secret = await holster.SEA.secret(alice, user.is)
60
+ const shared = await holster.SEA.encrypt("Hello Alice!", secret)
61
+ user.get("shared").put(shared, err => {
62
+ if (err) {
63
+ console.log(err)
64
+ return
65
+ }
66
+
67
+ console.log("Success! Bob wrote secret data to his account.")
68
+ })
69
+ })
70
+ })
71
+ }, 2000)
72
+
73
+ setTimeout(() => {
74
+ // Use Bob's public key to fetch shared data under his account.
75
+ holster.user(bob.pub).get("shared", enc => {
76
+ user.auth("alice", "password", async err => {
77
+ if (err) {
78
+ console.log(err)
79
+ return
80
+ }
81
+
82
+ console.log("Alice logged in again.", user.is)
83
+ const secret = await holster.SEA.secret(bob, user.is)
84
+ const shared = await holster.SEA.decrypt(enc, secret)
85
+ console.log("Alice's shared data from Bob:", shared)
86
+ })
87
+ })
88
+ }, 3000)
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Holster.wire - test examples</title>
6
+ </head>
7
+ <body>
8
+ <h2>Holster.wire - test examples</h2>
9
+ <p>
10
+ This file loads <code>wire.js</code> which contains examples that use the
11
+ Holster.wire API. These examples show how conflict resolution works, see
12
+ the console log for test output.
13
+ </p>
14
+ <p>
15
+ These tests also make two updates in random order, but the wire API can
16
+ set state vectors on updates to control which value should be applied.
17
+ This means when the "species" field is logged it will always be the same
18
+ no matter which order the tests run in.
19
+ </p>
20
+ <p>
21
+ The <code>wire.js</code> file is loaded as a module, which allows it to
22
+ import the development build of Holster. You can switch to the production
23
+ build by changing the import path at the top of the file.
24
+ </p>
25
+ <script type="module" src="./wire.js"></script>
26
+ </body>
27
+ </html>
@@ -0,0 +1,55 @@
1
+ // Switch src to build for the production version.
2
+ import Holster from "../src/holster.js"
3
+
4
+ // Set readFromDisk to true to force reading from disk.
5
+ // (Restart the server so that graph is not in memory.)
6
+ const readFromDisk = false
7
+
8
+ const holster = Holster({indexedDB: !readFromDisk})
9
+ globalThis.holster = holster
10
+
11
+ if (readFromDisk) {
12
+ // Wait for the websocket to connect.
13
+ setTimeout(() => {
14
+ holster.wire.get({"#": "FDSA", ".": "species"}, msg => {
15
+ console.log("read from disk:", msg)
16
+ })
17
+ }, 1000)
18
+ } else {
19
+ // Create two updates that could happen in either order.
20
+ setTimeout(() => {
21
+ const update1 = {
22
+ ASDF: {
23
+ _: {"#": "ASDF", ">": {name: 2, boss: 2}},
24
+ name: "Mark Nadal",
25
+ boss: {"#": "FDSA"},
26
+ },
27
+ FDSA: {
28
+ _: {"#": "FDSA", ">": {name: 2, species: 2, slave: 2}},
29
+ name: "Fluffy",
30
+ species: "a kitty",
31
+ slave: {"#": "ASDF"},
32
+ },
33
+ }
34
+ holster.wire.put(update1, msg => console.log("update1:", msg))
35
+ }, 1000 * Math.random())
36
+
37
+ setTimeout(() => {
38
+ const update2 = {
39
+ ASDF: {_: {"#": "ASDF", ">": {name: 1}}, name: "Mark"},
40
+ FDSA: {
41
+ _: {"#": "FDSA", ">": {species: 2, color: 3}},
42
+ species: "felis silvestris",
43
+ color: "ginger",
44
+ },
45
+ }
46
+ holster.wire.put(update2, msg => console.log("update2:", msg))
47
+ }, 1000 * Math.random())
48
+
49
+ // They should always produce the same result no matter which order.
50
+ setTimeout(() => {
51
+ holster.wire.get({"#": "FDSA", ".": "species"}, msg => {
52
+ console.log("get:", msg)
53
+ })
54
+ }, 2000)
55
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mblaney/holster",
3
+ "version": "0.1.0",
4
+ "description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "node --test",
9
+ "build": "esbuild src/holster.js --bundle --format=esm --sourcemap \"--external:node:*\" \"--external:ws\" --minify --outdir=build"
10
+ },
11
+ "keywords": [],
12
+ "author": "Malcolm Blaney",
13
+ "license": "Zlib OR MIT OR Apache-2.0",
14
+ "dependencies": {
15
+ "express": "^5.1.0",
16
+ "fs": "^0.0.1-security",
17
+ "ws": "^8.18.0"
18
+ },
19
+ "devDependencies": {
20
+ "esbuild": "0.25.3",
21
+ "mock-socket": "^9.3.1",
22
+ "prettier": "^3.4.2"
23
+ },
24
+ "prettier": {
25
+ "semi": false,
26
+ "bracketSpacing": false,
27
+ "arrowParens": "avoid"
28
+ },
29
+ "browser": {
30
+ "fs": false
31
+ },
32
+ "directories": {
33
+ "example": "examples",
34
+ "test": "test"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/mblaney/holster.git"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/mblaney/holster/issues"
42
+ },
43
+ "homepage": "https://github.com/mblaney/holster/wiki"
44
+ }
package/src/array.js ADDED
@@ -0,0 +1,34 @@
1
+ if (typeof btoa === "undefined") {
2
+ globalThis.btoa = data => Buffer.from(data, "binary").toString("base64")
3
+ globalThis.atob = data => Buffer.from(data, "base64").toString("binary")
4
+ }
5
+
6
+ // This is Array extended to have .toString(["utf8"|"hex"|"base64"])
7
+ function SeaArray() {}
8
+
9
+ Object.assign(SeaArray, {from: Array.from})
10
+ SeaArray.prototype = Object.create(Array.prototype)
11
+ SeaArray.prototype.toString = function (enc, start, end) {
12
+ if (!enc) enc = "utf8"
13
+ if (!start) start = 0
14
+
15
+ const length = this.length
16
+ if (enc === "hex") {
17
+ const buf = new Uint8Array(this)
18
+ return [...Array(((end && end + 1) || length) - start).keys()]
19
+ .map(i => buf[i + start].toString(16).padStart(2, "0"))
20
+ .join("")
21
+ }
22
+
23
+ if (enc === "utf8") {
24
+ return Array.from({length: (end || length) - start}, (_, i) =>
25
+ String.fromCharCode(this[i + start]),
26
+ ).join("")
27
+ }
28
+
29
+ if (enc === "base64") {
30
+ return btoa(this)
31
+ }
32
+ }
33
+
34
+ export default SeaArray
package/src/buffer.js ADDED
@@ -0,0 +1,83 @@
1
+ import SeaArray from "./array.js"
2
+
3
+ // This is Buffer implementation used in SEA. Functionality is mostly
4
+ // compatible with NodeJS "safe-buffer" and is used for encoding conversions
5
+ // between binary and "hex" | "utf8" | "base64"
6
+ // See documentation and validation for safe implementation in:
7
+ // https://github.com/feross/safe-buffer#update
8
+ function SafeBuffer(...props) {
9
+ console.warn("new SafeBuffer() is deprecated, please use SafeBuffer.from()")
10
+ return SafeBuffer.from(...props)
11
+ }
12
+
13
+ SafeBuffer.prototype = Object.create(Array.prototype)
14
+ Object.assign(SafeBuffer, {
15
+ // (data, enc) where typeof data === "string" then
16
+ // enc === "utf8"|"hex"|"base64"
17
+ from() {
18
+ if (!Object.keys(arguments).length || arguments[0] == null) {
19
+ throw new TypeError(
20
+ "First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object.",
21
+ )
22
+ }
23
+ const input = arguments[0]
24
+ let buf
25
+ if (typeof input === "string") {
26
+ const enc = arguments[1] || "utf8"
27
+ if (enc === "hex") {
28
+ const bytes = input
29
+ .match(/([\da-fA-F]{2})/g)
30
+ .map(byte => parseInt(byte, 16))
31
+ if (!bytes || !bytes.length) {
32
+ throw new TypeError("Invalid first argument for type 'hex'.")
33
+ }
34
+ buf = SeaArray.from(bytes)
35
+ } else if (enc === "utf8" || enc === "binary") {
36
+ const length = input.length
37
+ const words = new Uint16Array(length)
38
+ Array.from({length: length}, (_, i) => (words[i] = input.charCodeAt(i)))
39
+ buf = SeaArray.from(words)
40
+ } else if (enc === "base64") {
41
+ const dec = atob(input)
42
+ const length = dec.length
43
+ const bytes = new Uint8Array(length)
44
+ Array.from({length: length}, (_, i) => (bytes[i] = dec.charCodeAt(i)))
45
+ buf = SeaArray.from(bytes)
46
+ } else {
47
+ console.info("SafeBuffer.from unknown encoding: " + enc)
48
+ }
49
+ return buf
50
+ }
51
+ const length = input.byteLength ? input.byteLength : input.length
52
+ if (length) {
53
+ let buf
54
+ if (input instanceof ArrayBuffer) {
55
+ buf = new Uint8Array(input)
56
+ }
57
+ const check = SeaArray.from(buf || input)
58
+ return check
59
+ }
60
+ },
61
+ // This is "safe-buffer.alloc" sans encoding support
62
+ alloc(length, fill = 0) {
63
+ return SeaArray.from(
64
+ new Uint8Array(Array.from({length: length}, () => fill)),
65
+ )
66
+ },
67
+ // This puts together array of array like members
68
+ concat(arr) {
69
+ // octet array
70
+ if (!Array.isArray(arr)) {
71
+ throw new TypeError(
72
+ "First argument must be Array containing ArrayBuffer or Uint8Array instances.",
73
+ )
74
+ }
75
+ return SeaArray.from(
76
+ arr.reduce((ret, item) => ret.concat(Array.from(item)), []),
77
+ )
78
+ },
79
+ })
80
+ SafeBuffer.prototype.from = SafeBuffer.from
81
+ SafeBuffer.prototype.toString = SeaArray.prototype.toString
82
+
83
+ export default SafeBuffer
package/src/dup.js ADDED
@@ -0,0 +1,23 @@
1
+ const Dup = maxAge => {
2
+ // Allow maxAge to be passed in as tests wait on the setTimeout.
3
+ if (!maxAge) maxAge = 9000
4
+ const dup = {store: {}}
5
+ dup.check = id => (dup.store[id] ? dup.track(id) : false)
6
+ dup.track = id => {
7
+ // Keep the liveliness of the message up while it is being received.
8
+ dup.store[id] = Date.now()
9
+ if (!dup.expiry) {
10
+ dup.expiry = setTimeout(() => {
11
+ const now = Date.now()
12
+ Object.keys(dup.store).forEach(id => {
13
+ if (now - dup.store[id] > maxAge) delete dup.store[id]
14
+ })
15
+ dup.expiry = null
16
+ }, maxAge)
17
+ }
18
+ return id
19
+ }
20
+ return dup
21
+ }
22
+
23
+ export default Dup
package/src/get.js ADDED
@@ -0,0 +1,19 @@
1
+ const Get = (lex, graph) => {
2
+ const soul = lex["#"]
3
+ const key = typeof lex["."] === "string" ? lex["."] : false
4
+ var node = graph[soul]
5
+
6
+ // Can only return a node if a key is provided, because the graph may not
7
+ // have all the keys populated for a given soul. This is because Ham.mix
8
+ // only adds incoming changes to the graph.
9
+ if (!node || !key) return
10
+
11
+ let value = node[key]
12
+ if (!value) return
13
+
14
+ node = {_: node._, [key]: value}
15
+ node._[">"] = {[key]: node._[">"][key]}
16
+ return {[soul]: node}
17
+ }
18
+
19
+ export default Get
package/src/ham.js ADDED
@@ -0,0 +1,151 @@
1
+ import * as utils from "./utils.js"
2
+ import SEA from "./sea.js"
3
+
4
+ // state and value are the incoming changes.
5
+ // currentState and currentValue are the current graph data.
6
+ const Ham = (state, currentState, value, currentValue) => {
7
+ if (state < currentState) return {historical: true}
8
+
9
+ if (state > currentState) return {incoming: true}
10
+
11
+ // state is equal to currentState, lexically compare to resolve conflict.
12
+ if (typeof value !== "string") {
13
+ value = JSON.stringify(value) || ""
14
+ }
15
+ if (typeof currentValue !== "string") {
16
+ currentValue = JSON.stringify(currentValue) || ""
17
+ }
18
+ // No update required.
19
+ if (value === currentValue) return {state: true}
20
+
21
+ // Keep the current value.
22
+ if (value < currentValue) return {current: true}
23
+
24
+ // Otherwise update using the incoming value.
25
+ return {incoming: true}
26
+ }
27
+
28
+ Ham.mix = async (change, graph, secure, listen) => {
29
+ var machine = Date.now()
30
+ var now = {}
31
+ var defer = {}
32
+ let wait = 0
33
+
34
+ for (const soul of Object.keys(change)) {
35
+ const node = change[soul]
36
+ let updated = false
37
+ let alias = false
38
+ let nodeWait = 0
39
+ let pub = ""
40
+ let verify = secure
41
+
42
+ if (!node || !node._) continue
43
+
44
+ // If a signature and public key are provided then always verify.
45
+ if (node._.s && node._.p) verify = true
46
+
47
+ // Special case if soul starts with "~". Node must be system data ie,
48
+ // ~@alias or ~publickey. For aliases, key and value must be a self
49
+ // identifying rel. For public keys, data needs to be signed and verified.
50
+ // (This is also true for any data when the secure flag is used.)
51
+ if (soul.length > 1 && soul[0] === "~") {
52
+ if (soul[1] === "@") {
53
+ alias = true
54
+ verify = false
55
+ } else {
56
+ if (node._.p && soul != "~" + node._.p) {
57
+ console.log(`error public key does not match for soul: ${soul}`)
58
+ continue
59
+ }
60
+
61
+ // Need to be able to create public key data before a user is
62
+ // authenticated, in this case the soul is the public key which
63
+ // should've been used to sign the data.
64
+ node._.p = soul.slice(1)
65
+ verify = true
66
+ }
67
+ }
68
+ if (verify) {
69
+ if (!node._.s || !node._.p) {
70
+ console.log("error signature and public key required to verify data")
71
+ continue
72
+ }
73
+
74
+ if (!(await SEA.verify({m: node, s: node._.s}, {pub: node._.p}))) {
75
+ console.log(`error could not verify soul: ${soul}`)
76
+ continue
77
+ }
78
+ }
79
+
80
+ for (const key of Object.keys(node)) {
81
+ if (key === "_") continue
82
+
83
+ const value = node[key]
84
+ const state = node._[">"][key]
85
+ const currentValue = (graph[soul] || {})[key]
86
+ const currentState = (graph[soul] || {_: {">": {}}})._[">"][key] || 0
87
+
88
+ if (alias && key !== utils.rel.is(value)) continue
89
+
90
+ // Defer the update if ahead of machine time.
91
+ const skew = state - machine
92
+ if (skew > 0) {
93
+ // Ignore update if ahead by more than 24 hours.
94
+ if (skew > 86400000) continue
95
+
96
+ // Wait the shortest difference before trying the updates again.
97
+ if (wait === 0 || skew < wait) wait = nodeWait = skew
98
+ if (!defer[soul]) {
99
+ defer[soul] = {_: {"#": soul, ">": {}, s: node._.s, p: node._.p}}
100
+ }
101
+ defer[soul][key] = value
102
+ defer[soul]._[">"][key] = state
103
+ } else {
104
+ const result = Ham(state, currentState, value, currentValue)
105
+ if (result.incoming) {
106
+ if (!now[soul]) {
107
+ now[soul] = {_: {"#": soul, ">": {}, s: node._.s, p: node._.p}}
108
+ }
109
+ // TODO: graph should not just grow indefintitely in memory.
110
+ // Need to have a max size after which start dropping the oldest state
111
+ // Do something similar to Dup which can handle deletes?
112
+ if (!graph[soul]) {
113
+ graph[soul] = {_: {"#": soul, ">": {}, s: node._.s, p: node._.p}}
114
+ }
115
+ graph[soul][key] = now[soul][key] = value
116
+ graph[soul]._[">"][key] = now[soul]._[">"][key] = state
117
+ // Call event listeners for update on key, mix is called before
118
+ // put has finished so wait for what could be multiple nested
119
+ // updates on a node.
120
+ if (listen[soul]) {
121
+ setTimeout(() => {
122
+ if (listen[soul]) {
123
+ listen[soul]
124
+ .filter(l => utils.match(l["."], key))
125
+ .forEach(l => l.cb())
126
+ }
127
+ }, 100)
128
+ }
129
+ updated = true
130
+ }
131
+ }
132
+ }
133
+
134
+ if (verify && nodeWait !== 0 && now[soul]) {
135
+ // Secure updates can't be split, so move now to deferred as well.
136
+ Object.assign(defer[soul], now[soul])
137
+ delete now[soul]
138
+ }
139
+ // Call event listeners for update on soul.
140
+ if (updated && listen[soul]) {
141
+ setTimeout(() => {
142
+ if (listen[soul]) {
143
+ listen[soul].filter(l => utils.match(l["."])).forEach(l => l.cb())
144
+ }
145
+ }, 100)
146
+ }
147
+ }
148
+ return {now: now, defer: defer, wait: wait}
149
+ }
150
+
151
+ export default Ham