@dtudury/streamo 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.
- package/.claude/settings.local.json +10 -0
- package/LICENSE +661 -0
- package/README.md +194 -0
- package/ROADMAP.md +111 -0
- package/bin/streamo.js +238 -0
- package/jsconfig.json +9 -0
- package/package.json +26 -0
- package/public/apps/chat/index.html +61 -0
- package/public/apps/chat/main.js +144 -0
- package/public/apps/styles/proto.css +71 -0
- package/public/index.html +109 -0
- package/public/streamo/Addressifier.js +212 -0
- package/public/streamo/CodecRegistry.js +195 -0
- package/public/streamo/ContentMap.js +79 -0
- package/public/streamo/DESIGN.md +61 -0
- package/public/streamo/Repo.js +176 -0
- package/public/streamo/Repo.test.js +82 -0
- package/public/streamo/RepoRegistry.js +91 -0
- package/public/streamo/RepoRegistry.test.js +87 -0
- package/public/streamo/Signature.js +15 -0
- package/public/streamo/Signer.js +91 -0
- package/public/streamo/Streamo.js +392 -0
- package/public/streamo/Streamo.test.js +205 -0
- package/public/streamo/archiveSync.js +62 -0
- package/public/streamo/chat-cli.js +122 -0
- package/public/streamo/chat-server.js +60 -0
- package/public/streamo/codecs.js +400 -0
- package/public/streamo/fileSync.js +238 -0
- package/public/streamo/h.js +202 -0
- package/public/streamo/h.mount.test.js +67 -0
- package/public/streamo/h.test.js +121 -0
- package/public/streamo/mount.js +248 -0
- package/public/streamo/originSync.js +60 -0
- package/public/streamo/outletSync.js +105 -0
- package/public/streamo/registrySync.js +333 -0
- package/public/streamo/registrySync.test.js +373 -0
- package/public/streamo/s3Sync.js +99 -0
- package/public/streamo/stateFileSync.js +17 -0
- package/public/streamo/sync.test.js +98 -0
- package/public/streamo/utils/NestedSet.js +41 -0
- package/public/streamo/utils/Recaller.js +77 -0
- package/public/streamo/utils/mockDOM.js +113 -0
- package/public/streamo/utils/nextTick.js +22 -0
- package/public/streamo/utils/noble-secp256k1.js +602 -0
- package/public/streamo/utils/testing.js +90 -0
- package/public/streamo/utils.js +57 -0
- package/public/streamo/webSync.js +118 -0
- package/scripts/serve.js +15 -0
- package/smoke.test.js +132 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe } from './utils/testing.js'
|
|
2
|
+
import { Streamo } from './Streamo.js'
|
|
3
|
+
import { Repo } from './Repo.js'
|
|
4
|
+
import { RepoRegistry } from './RepoRegistry.js'
|
|
5
|
+
import { archiveSync } from './archiveSync.js'
|
|
6
|
+
|
|
7
|
+
function archiveRegistry (dir) {
|
|
8
|
+
return new RepoRegistry(async key => {
|
|
9
|
+
const repo = new Repo()
|
|
10
|
+
await archiveSync(repo, dir, key)
|
|
11
|
+
return repo
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe(import.meta.url, ({ test }) => {
|
|
16
|
+
test('plain registry creates in-memory repositories with no factory', async ({ assert }) => {
|
|
17
|
+
const registry = new RepoRegistry()
|
|
18
|
+
const s = await registry.open('anykey')
|
|
19
|
+
assert.ok(s instanceof Repo)
|
|
20
|
+
s.set({ x: 1 })
|
|
21
|
+
assert.equal(s.get('x'), 1)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('open creates a repository and returns the same instance on repeat calls', async ({ assert }) => {
|
|
25
|
+
const registry = new RepoRegistry()
|
|
26
|
+
const s1 = await registry.open('aabbcc')
|
|
27
|
+
const s2 = await registry.open('aabbcc')
|
|
28
|
+
assert.ok(s1 === s2, 'same instance returned')
|
|
29
|
+
assert.equal(registry.size, 1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('open creates independent repositories for different keys', async ({ assert }) => {
|
|
33
|
+
const registry = new RepoRegistry()
|
|
34
|
+
const s1 = await registry.open('key1')
|
|
35
|
+
const s2 = await registry.open('key2')
|
|
36
|
+
assert.ok(s1 !== s2)
|
|
37
|
+
assert.equal(registry.size, 2)
|
|
38
|
+
s1.set({ from: 'key1' })
|
|
39
|
+
s2.set({ from: 'key2' })
|
|
40
|
+
assert.equal(s1.get('from'), 'key1')
|
|
41
|
+
assert.equal(s2.get('from'), 'key2')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('concurrent open() calls return the same instance', async ({ assert }) => {
|
|
45
|
+
let created = 0
|
|
46
|
+
const registry = new RepoRegistry(async () => {
|
|
47
|
+
created++
|
|
48
|
+
await new Promise(r => setTimeout(r, 10))
|
|
49
|
+
return new Repo()
|
|
50
|
+
})
|
|
51
|
+
const [s1, s2, s3] = await Promise.all([
|
|
52
|
+
registry.open('k'),
|
|
53
|
+
registry.open('k'),
|
|
54
|
+
registry.open('k')
|
|
55
|
+
])
|
|
56
|
+
assert.equal(created, 1, 'factory called only once')
|
|
57
|
+
assert.ok(s1 === s2 && s2 === s3, 'all calls return same instance')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('get returns undefined for unopened or still-opening keys', async ({ assert }) => {
|
|
61
|
+
const registry = new RepoRegistry()
|
|
62
|
+
assert.equal(registry.get('nope'), undefined)
|
|
63
|
+
await registry.open('exists')
|
|
64
|
+
assert.ok(registry.get('exists') instanceof Streamo)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('iterates over fully-opened repositories only', async ({ assert }) => {
|
|
68
|
+
const registry = new RepoRegistry()
|
|
69
|
+
await registry.open('a')
|
|
70
|
+
await registry.open('b')
|
|
71
|
+
const entries = [...registry]
|
|
72
|
+
assert.equal(entries.length, 2)
|
|
73
|
+
assert.deepEqual(entries.map(([k]) => k).sort(), ['a', 'b'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('archive factory persists and reloads repository data', async ({ assert }) => {
|
|
77
|
+
const dir = '/tmp/repository-registry-persist-test-' + Date.now()
|
|
78
|
+
const r1 = archiveRegistry(dir)
|
|
79
|
+
const s1 = await r1.open('testkey')
|
|
80
|
+
s1.set({ saved: true })
|
|
81
|
+
await new Promise(r => setTimeout(r, 50))
|
|
82
|
+
|
|
83
|
+
const r2 = archiveRegistry(dir)
|
|
84
|
+
const s2 = await r2.open('testkey')
|
|
85
|
+
assert.equal(s2.get('saved'), true, 'data survived registry reload')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A secp256k1 signature over a range of stream bytes.
|
|
3
|
+
* `address` is the first byte of the signed range.
|
|
4
|
+
* `compactRawBytes` is the 64-byte compact signature.
|
|
5
|
+
*/
|
|
6
|
+
export class Signature {
|
|
7
|
+
/**
|
|
8
|
+
* @param {number} address
|
|
9
|
+
* @param {Uint8Array} compactRawBytes
|
|
10
|
+
*/
|
|
11
|
+
constructor (address, compactRawBytes) {
|
|
12
|
+
this.address = address
|
|
13
|
+
this.compactRawBytes = compactRawBytes
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getPublicKey, signAsync, verify } from './utils/noble-secp256k1.js'
|
|
2
|
+
|
|
3
|
+
const cryptoSubtle = typeof crypto !== 'undefined' ? crypto.subtle : (await import('crypto')).webcrypto.subtle
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Derive a deterministic private key from (name, password) using PBKDF2.
|
|
7
|
+
* @param {string} name
|
|
8
|
+
* @param {string} password
|
|
9
|
+
* @param {number} [iterations=100000]
|
|
10
|
+
* @returns {Promise.<string>} hex-encoded key
|
|
11
|
+
*/
|
|
12
|
+
async function deriveKey (name, password, iterations = 100000) {
|
|
13
|
+
const enc = new TextEncoder()
|
|
14
|
+
const base = await cryptoSubtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits', 'deriveKey'])
|
|
15
|
+
const key = await cryptoSubtle.deriveKey(
|
|
16
|
+
{ name: 'PBKDF2', salt: enc.encode(name), iterations, hash: 'SHA-256' },
|
|
17
|
+
base,
|
|
18
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
19
|
+
true,
|
|
20
|
+
['sign']
|
|
21
|
+
)
|
|
22
|
+
const raw = new Uint8Array(await cryptoSubtle.exportKey('raw', key))
|
|
23
|
+
return Array.from(raw.slice(32)).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function sha256 (uint8Array) {
|
|
27
|
+
return new Uint8Array(await cryptoSubtle.digest('SHA-256', uint8Array))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Signs stream content using secp256k1.
|
|
32
|
+
* Each stream name gets its own deterministic key pair derived from
|
|
33
|
+
* the user's username and password.
|
|
34
|
+
*/
|
|
35
|
+
export class Signer {
|
|
36
|
+
#keysByName = {}
|
|
37
|
+
#hashwordPromise
|
|
38
|
+
#iterations
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} username
|
|
42
|
+
* @param {string} password
|
|
43
|
+
* @param {number} [iterations=100000]
|
|
44
|
+
*/
|
|
45
|
+
constructor (username, password, iterations = 100000) {
|
|
46
|
+
this.username = username
|
|
47
|
+
this.#iterations = iterations
|
|
48
|
+
this.#hashwordPromise = deriveKey(username, password, iterations)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} streamName
|
|
53
|
+
* @returns {Promise.<{privateKey: string, publicKey: Uint8Array}>}
|
|
54
|
+
*/
|
|
55
|
+
async keysFor (streamName) {
|
|
56
|
+
if (!this.#keysByName[streamName]) {
|
|
57
|
+
const hashword = await this.#hashwordPromise
|
|
58
|
+
const privateKey = await deriveKey(streamName, hashword, this.#iterations)
|
|
59
|
+
const publicKey = getPublicKey(privateKey)
|
|
60
|
+
this.#keysByName[streamName] = { privateKey, publicKey }
|
|
61
|
+
}
|
|
62
|
+
return this.#keysByName[streamName]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} streamName
|
|
67
|
+
* @param {Uint8Array} bytes
|
|
68
|
+
* @returns {Promise.<Uint8Array>} 64-byte compact signature
|
|
69
|
+
*/
|
|
70
|
+
async sign (streamName, bytes) {
|
|
71
|
+
const { privateKey } = await this.keysFor(streamName)
|
|
72
|
+
const hash = await sha256(bytes)
|
|
73
|
+
const sig = await signAsync(hash, privateKey)
|
|
74
|
+
return sig.toCompactRawBytes()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {Uint8Array} publicKey
|
|
80
|
+
* @param {Uint8Array} bytes
|
|
81
|
+
* @param {Uint8Array} compactRawBytes
|
|
82
|
+
* @returns {Promise.<boolean>}
|
|
83
|
+
*/
|
|
84
|
+
export async function verifySignature (publicKey, bytes, compactRawBytes) {
|
|
85
|
+
try {
|
|
86
|
+
const hash = await sha256(bytes)
|
|
87
|
+
return verify(compactRawBytes, hash, publicKey)
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { Recaller } from './utils/Recaller.js'
|
|
2
|
+
import { CodecRegistry } from './CodecRegistry.js'
|
|
3
|
+
import { Signature } from './Signature.js'
|
|
4
|
+
import { verifySignature } from './Signer.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thrown by conditionalSet() when the streamo has advanced past the expected tip.
|
|
8
|
+
* Catch this to detect write conflicts and retry with a fresh read.
|
|
9
|
+
*/
|
|
10
|
+
export class ConflictError extends Error {
|
|
11
|
+
/**
|
|
12
|
+
* @param {number} expectedTip byteLength the caller observed
|
|
13
|
+
* @param {number} actualTip byteLength at the moment of the attempted write
|
|
14
|
+
*/
|
|
15
|
+
constructor (expectedTip, actualTip) {
|
|
16
|
+
super(`conflict: expected tip ${expectedTip} but streamo is at ${actualTip}`)
|
|
17
|
+
this.name = 'ConflictError'
|
|
18
|
+
this.expectedTip = expectedTip
|
|
19
|
+
this.actualTip = actualTip
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Yield every path where addrA and addrB differ, including the root.
|
|
25
|
+
* Compares by address so unchanged subtrees are skipped in O(1).
|
|
26
|
+
*/
|
|
27
|
+
export function * changedPaths (streamo, addrA, addrB, path = []) {
|
|
28
|
+
if (addrA === addrB) return
|
|
29
|
+
yield path
|
|
30
|
+
const refsA = addrA !== undefined ? streamo.decode(addrA, true) : undefined
|
|
31
|
+
const refsB = addrB !== undefined ? streamo.decode(addrB, true) : undefined
|
|
32
|
+
const isPlain = v => v != null && typeof v === 'object' && (Array.isArray(v) || Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
|
|
33
|
+
const objA = isPlain(refsA)
|
|
34
|
+
const objB = isPlain(refsB)
|
|
35
|
+
if (objA || objB) {
|
|
36
|
+
const keys = new Set([...Object.keys(refsA ?? {}), ...Object.keys(refsB ?? {})])
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
const a = objA ? refsA[key] : undefined
|
|
39
|
+
const b = objB ? refsB[key] : undefined
|
|
40
|
+
if (a !== b) yield * changedPaths(streamo, a, b, [...path, key])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A Streamo is a reactive, signed, append-only data store.
|
|
47
|
+
*
|
|
48
|
+
* It combines:
|
|
49
|
+
* - CodecRegistry: encode/decode any JS value to/from bytes
|
|
50
|
+
* - Recaller: fine-grained reactive dependency tracking (watch/get/set)
|
|
51
|
+
* - secp256k1 signing: sign the streamo contents, verify signatures
|
|
52
|
+
*
|
|
53
|
+
* This is the primary user-facing class. The layers below it
|
|
54
|
+
* (Addressifier, CodecRegistry) exist to serve it.
|
|
55
|
+
*/
|
|
56
|
+
export class Streamo extends CodecRegistry {
|
|
57
|
+
#recaller
|
|
58
|
+
#signedLength = 0
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Recaller} [recaller]
|
|
62
|
+
*/
|
|
63
|
+
constructor (recaller = new Recaller('Streamo')) {
|
|
64
|
+
super()
|
|
65
|
+
this.#recaller = recaller
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get recaller () { return this.#recaller }
|
|
69
|
+
|
|
70
|
+
get byteLength () {
|
|
71
|
+
this.#recaller.reportKeyAccess(this, 'length')
|
|
72
|
+
return super.byteLength
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Append code and notify reactive watchers.
|
|
77
|
+
* @param {Uint8Array} code
|
|
78
|
+
* @returns {number}
|
|
79
|
+
*/
|
|
80
|
+
append (code) {
|
|
81
|
+
const address = super.append(code)
|
|
82
|
+
this.#recaller.reportKeyMutation(this, 'length')
|
|
83
|
+
return address
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decode the value at a path within the most-recently-appended value,
|
|
88
|
+
* registering reactive dependencies so watchers re-run on changes.
|
|
89
|
+
*
|
|
90
|
+
* If the first argument is a number it is treated as an explicit address
|
|
91
|
+
* (no dependency registered). Otherwise byteLength is accessed (dependency
|
|
92
|
+
* registered) and all arguments are treated as a path into the decoded value.
|
|
93
|
+
*
|
|
94
|
+
* @param {...(number|string)} args
|
|
95
|
+
* @returns {any}
|
|
96
|
+
*/
|
|
97
|
+
get (...args) {
|
|
98
|
+
let address
|
|
99
|
+
if (typeof args[0] === 'number') {
|
|
100
|
+
address = args.shift()
|
|
101
|
+
} else {
|
|
102
|
+
address = this.valueAddress
|
|
103
|
+
// 'length': re-run when external bytes arrive (append() fires 'length').
|
|
104
|
+
// path string: re-run when set() mutates this specific path via changedPaths.
|
|
105
|
+
this.#recaller.reportKeyAccess(this, 'length')
|
|
106
|
+
this.#recaller.reportKeyAccess(this, JSON.stringify(args))
|
|
107
|
+
}
|
|
108
|
+
if (address < 0) return undefined
|
|
109
|
+
let value = this.decode(address)
|
|
110
|
+
for (const key of args) {
|
|
111
|
+
if (value == null) return undefined
|
|
112
|
+
value = value[key]
|
|
113
|
+
}
|
|
114
|
+
return value
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Encode and append a new value, optionally updating at a path within the
|
|
119
|
+
* current top-level decoded value. Notifies reactive watchers of which paths
|
|
120
|
+
* changed.
|
|
121
|
+
*
|
|
122
|
+
* Signature: set([address,] ...path, value)
|
|
123
|
+
* - If first arg is a number, use it as the base address.
|
|
124
|
+
* - The last argument is always the value to set.
|
|
125
|
+
* - Intermediate arguments are the path to update.
|
|
126
|
+
*
|
|
127
|
+
* @param {...(number|string|any)} args
|
|
128
|
+
* @returns {number} address of the newly appended code
|
|
129
|
+
*/
|
|
130
|
+
set (...args) {
|
|
131
|
+
const baseAddress = typeof args[0] === 'number' ? args.shift() : this.valueAddress
|
|
132
|
+
const value = args.pop()
|
|
133
|
+
const path = args
|
|
134
|
+
|
|
135
|
+
const prevAddress = super.byteLength > 0 ? this.valueAddress : undefined
|
|
136
|
+
|
|
137
|
+
if (path.length === 0 || baseAddress < 0) {
|
|
138
|
+
// Whole-value set: encode and store, bypassing Streamo.append so 'length'
|
|
139
|
+
// is not fired — changedPaths will emit the right path-level mutations.
|
|
140
|
+
let encodedValue = value
|
|
141
|
+
if (path.length > 0) {
|
|
142
|
+
// Empty streamo with a path: build nested object from path
|
|
143
|
+
let obj = value
|
|
144
|
+
for (let i = path.length - 1; i >= 0; i--) obj = { [path[i]]: obj }
|
|
145
|
+
encodedValue = obj
|
|
146
|
+
}
|
|
147
|
+
super.append(this.encode(encodedValue))
|
|
148
|
+
} else {
|
|
149
|
+
// Path update: navigate via asRefs to avoid decoding untouched subtrees,
|
|
150
|
+
// then rebuild only the changed path bottom-up, reusing sibling addresses.
|
|
151
|
+
const levels = []
|
|
152
|
+
let addr = baseAddress
|
|
153
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
154
|
+
const refs = this.asRefs(addr)
|
|
155
|
+
levels.push({ refs, key: path[i] })
|
|
156
|
+
addr = Array.isArray(refs) ? refs[+path[i]] : refs[path[i]]
|
|
157
|
+
}
|
|
158
|
+
levels.push({ refs: this.asRefs(addr), key: path[path.length - 1] })
|
|
159
|
+
|
|
160
|
+
// Encode the new leaf value
|
|
161
|
+
const leafCode = this.encode(value)
|
|
162
|
+
let childAddr = this.addressOf(leafCode) ?? super.append(leafCode)
|
|
163
|
+
|
|
164
|
+
// Rebuild from leaf to root, reusing unchanged siblings by address
|
|
165
|
+
for (let i = levels.length - 1; i >= 0; i--) {
|
|
166
|
+
const { refs, key } = levels[i]
|
|
167
|
+
const newRefs = Array.isArray(refs) ? [...refs] : { ...refs }
|
|
168
|
+
newRefs[Array.isArray(refs) ? +key : key] = childAddr
|
|
169
|
+
const code = this.encode(newRefs, true)
|
|
170
|
+
childAddr = this.addressOf(code) ?? super.append(code)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const newAddress = super.byteLength - 1
|
|
175
|
+
for (const changed of changedPaths(this, prevAddress, newAddress)) {
|
|
176
|
+
this.#recaller.reportKeyMutation(this, JSON.stringify(changed))
|
|
177
|
+
}
|
|
178
|
+
return newAddress
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Navigate a path and return refs (addresses instead of decoded values).
|
|
183
|
+
* With no path, returns root refs. Returns a plain number if the target is
|
|
184
|
+
* a leaf (non-object/array), or undefined if the path doesn't exist.
|
|
185
|
+
*
|
|
186
|
+
* @param {...string} path
|
|
187
|
+
* @returns {Object|number|undefined}
|
|
188
|
+
*/
|
|
189
|
+
getRefs (...path) {
|
|
190
|
+
let address = this.valueAddress
|
|
191
|
+
if (address < 0) return undefined
|
|
192
|
+
for (const key of path) {
|
|
193
|
+
const refs = this.asRefs(address)
|
|
194
|
+
if (typeof refs === 'number') return undefined
|
|
195
|
+
address = Array.isArray(refs) ? refs[+key] : refs[key]
|
|
196
|
+
if (address === undefined) return undefined
|
|
197
|
+
}
|
|
198
|
+
return this.asRefs(address)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Like set(), but the last argument is an address (number) rather than a
|
|
203
|
+
* decoded value. Rebuilds only the changed path bottom-up, reusing sibling
|
|
204
|
+
* addresses — same as set() but skips the leaf-encoding step.
|
|
205
|
+
*
|
|
206
|
+
* Requires at least one path key and an existing object at that path.
|
|
207
|
+
*
|
|
208
|
+
* @param {...(string|number)} args ...path, address
|
|
209
|
+
* @returns {number} address of the newly appended code
|
|
210
|
+
*/
|
|
211
|
+
setRefs (...args) {
|
|
212
|
+
let childAddr = args.pop()
|
|
213
|
+
const path = args
|
|
214
|
+
const baseAddress = this.valueAddress
|
|
215
|
+
const prevAddress = super.byteLength > 0 ? this.valueAddress : undefined
|
|
216
|
+
|
|
217
|
+
const levels = []
|
|
218
|
+
let addr = baseAddress
|
|
219
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
220
|
+
const refs = this.asRefs(addr)
|
|
221
|
+
levels.push({ refs, key: path[i] })
|
|
222
|
+
addr = Array.isArray(refs) ? refs[+path[i]] : refs[path[i]]
|
|
223
|
+
}
|
|
224
|
+
levels.push({ refs: this.asRefs(addr), key: path[path.length - 1] })
|
|
225
|
+
|
|
226
|
+
for (let i = levels.length - 1; i >= 0; i--) {
|
|
227
|
+
const { refs, key } = levels[i]
|
|
228
|
+
const newRefs = Array.isArray(refs) ? [...refs] : { ...refs }
|
|
229
|
+
newRefs[Array.isArray(refs) ? +key : key] = childAddr
|
|
230
|
+
const code = this.encode(newRefs, true)
|
|
231
|
+
childAddr = this.addressOf(code) ?? super.append(code)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const newAddress = super.byteLength - 1
|
|
235
|
+
for (const changed of changedPaths(this, prevAddress, newAddress)) {
|
|
236
|
+
this.#recaller.reportKeyMutation(this, JSON.stringify(changed))
|
|
237
|
+
}
|
|
238
|
+
return newAddress
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Call f immediately, tracking get() calls. Re-runs f whenever a
|
|
243
|
+
* subsequent set() touches a path that was accessed.
|
|
244
|
+
* @param {string} name
|
|
245
|
+
* @param {function} f
|
|
246
|
+
*/
|
|
247
|
+
watch (name, f) {
|
|
248
|
+
this.#recaller.watch(name, f)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Stop watching a function that was previously passed to watch().
|
|
253
|
+
* @param {function} f
|
|
254
|
+
*/
|
|
255
|
+
unwatch (f) {
|
|
256
|
+
this.#recaller.unwatch(f)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Like set(), but only succeeds if the streamo's current byteLength equals
|
|
261
|
+
* `expectedTip` — i.e., nothing has been written since the caller last read.
|
|
262
|
+
*
|
|
263
|
+
* Throws ConflictError when the precondition fails. Callers should catch it,
|
|
264
|
+
* re-read the latest state, re-apply their change, and retry.
|
|
265
|
+
*
|
|
266
|
+
* @param {number} expectedTip byteLength observed when the change was prepared
|
|
267
|
+
* @param {...(string|any)} args same arguments as set()
|
|
268
|
+
* @returns {number} address of the newly appended code
|
|
269
|
+
*/
|
|
270
|
+
conditionalSet (expectedTip, ...args) {
|
|
271
|
+
const actual = super.byteLength
|
|
272
|
+
if (actual !== expectedTip) throw new ConflictError(expectedTip, actual)
|
|
273
|
+
return this.set(...args)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Snapshot this streamo up to (and including) `address`.
|
|
278
|
+
* The returned Streamo shares no mutable state with the original.
|
|
279
|
+
* @param {number} address
|
|
280
|
+
* @param {Recaller} [recaller]
|
|
281
|
+
* @returns {Streamo}
|
|
282
|
+
*/
|
|
283
|
+
clone (address, recaller = this.#recaller) {
|
|
284
|
+
return this._applyClone(new Streamo(recaller), address)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Signing ──────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Address of the most-recently-appended non-signature chunk.
|
|
291
|
+
* After streamo.sign() appends a SIGNATURE chunk, byteLength - 1 points to the
|
|
292
|
+
* signature rather than the user data. This getter skips backward past any
|
|
293
|
+
* trailing SIGNATURE chunks so get() and set() always operate on real data.
|
|
294
|
+
*/
|
|
295
|
+
get valueAddress () {
|
|
296
|
+
let address = super.byteLength - 1
|
|
297
|
+
while (address >= 0) {
|
|
298
|
+
const code = this.resolve(address)
|
|
299
|
+
if (this.footerToCodec[code.at(-1)]?.type !== 'SIGNATURE') break
|
|
300
|
+
address -= code.length
|
|
301
|
+
}
|
|
302
|
+
return address
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Byte length that has been covered by a signature. */
|
|
306
|
+
get signedLength () { return this.#signedLength }
|
|
307
|
+
|
|
308
|
+
/** @override Also resets the signed-length cursor. */
|
|
309
|
+
_reset () {
|
|
310
|
+
super._reset()
|
|
311
|
+
this.#signedLength = 0
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sign the bytes appended since the last signature (or from the start).
|
|
316
|
+
* Appends the signature as a new chunk and advances the signed cursor.
|
|
317
|
+
*
|
|
318
|
+
* @param {import('./Signer.js').Signer} signer
|
|
319
|
+
* @param {string} streamoName
|
|
320
|
+
* @returns {Promise.<Signature>}
|
|
321
|
+
*/
|
|
322
|
+
async sign (signer, streamoName) {
|
|
323
|
+
const before = super.byteLength
|
|
324
|
+
const bytes = this.slice(this.#signedLength, before - 1)
|
|
325
|
+
const compactRawBytes = await signer.sign(streamoName, bytes)
|
|
326
|
+
if (super.byteLength !== before) throw new Error('streamo was modified while signing')
|
|
327
|
+
const sig = new Signature(this.#signedLength, compactRawBytes)
|
|
328
|
+
this.append(this.encode(sig))
|
|
329
|
+
this.#signedLength = before
|
|
330
|
+
return sig
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Verify a signature against this streamo's contents.
|
|
335
|
+
* @param {Signature} sig
|
|
336
|
+
* @param {Uint8Array} publicKey
|
|
337
|
+
* @returns {Promise.<boolean>}
|
|
338
|
+
*/
|
|
339
|
+
async verify (sig, publicKey) {
|
|
340
|
+
const sigCode = this.encode(sig)
|
|
341
|
+
const sigAddress = this.addressOf(sigCode)
|
|
342
|
+
const bytes = this.slice(sig.address, sigAddress - sigCode.length)
|
|
343
|
+
return verifySignature(publicKey, bytes, sig.compactRawBytes)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Like makeWritableStream(), but verifies every SIGNATURE chunk against
|
|
348
|
+
* `publicKey` before accepting it. Non-signature chunks are appended as
|
|
349
|
+
* normal; the entire write is rejected (WritableStream errors) if any
|
|
350
|
+
* signature fails to verify.
|
|
351
|
+
*
|
|
352
|
+
* Use this when receiving data from an untrusted source (a peer, a file
|
|
353
|
+
* written by someone else) to ensure every signed range is authentic.
|
|
354
|
+
*
|
|
355
|
+
* @param {Uint8Array} publicKey
|
|
356
|
+
* @param {number} [maxFrameSize]
|
|
357
|
+
* @returns {WritableStream}
|
|
358
|
+
*/
|
|
359
|
+
makeVerifiedWritableStream (publicKey, maxFrameSize = 64 * 1024 * 1024) {
|
|
360
|
+
const self = this
|
|
361
|
+
let buf = new Uint8Array(0)
|
|
362
|
+
return new WritableStream({
|
|
363
|
+
async write (incoming) {
|
|
364
|
+
const next = new Uint8Array(buf.length + incoming.length)
|
|
365
|
+
next.set(buf); next.set(incoming, buf.length)
|
|
366
|
+
buf = next
|
|
367
|
+
while (buf.length >= 4) {
|
|
368
|
+
const len = new Uint32Array(buf.slice(0, 4).buffer)[0]
|
|
369
|
+
if (len === 0) throw new Error('malformed frame: zero-length chunk')
|
|
370
|
+
if (len > maxFrameSize) throw new Error(`malformed frame: length ${len} exceeds ${maxFrameSize}`)
|
|
371
|
+
if (buf.length < 4 + len) break
|
|
372
|
+
const code = buf.slice(4, 4 + len)
|
|
373
|
+
buf = buf.slice(4 + len)
|
|
374
|
+
|
|
375
|
+
if (self.addressOf(code) !== undefined) continue // already present, skip
|
|
376
|
+
|
|
377
|
+
// If this is a SIGNATURE chunk, verify it covers the bytes since its
|
|
378
|
+
// stated start address before we accept it into the store.
|
|
379
|
+
const codec = self.footerToCodec[code.at(-1)]
|
|
380
|
+
if (codec?.type === 'SIGNATURE') {
|
|
381
|
+
const sig = self.decode(code)
|
|
382
|
+
const bytes = self.slice(sig.address, self.byteLength - 1)
|
|
383
|
+
const valid = await verifySignature(publicKey, bytes, sig.compactRawBytes)
|
|
384
|
+
if (!valid) throw new Error('signature verification failed')
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
self.append(code)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
}
|