@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.
Files changed (49) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +661 -0
  3. package/README.md +194 -0
  4. package/ROADMAP.md +111 -0
  5. package/bin/streamo.js +238 -0
  6. package/jsconfig.json +9 -0
  7. package/package.json +26 -0
  8. package/public/apps/chat/index.html +61 -0
  9. package/public/apps/chat/main.js +144 -0
  10. package/public/apps/styles/proto.css +71 -0
  11. package/public/index.html +109 -0
  12. package/public/streamo/Addressifier.js +212 -0
  13. package/public/streamo/CodecRegistry.js +195 -0
  14. package/public/streamo/ContentMap.js +79 -0
  15. package/public/streamo/DESIGN.md +61 -0
  16. package/public/streamo/Repo.js +176 -0
  17. package/public/streamo/Repo.test.js +82 -0
  18. package/public/streamo/RepoRegistry.js +91 -0
  19. package/public/streamo/RepoRegistry.test.js +87 -0
  20. package/public/streamo/Signature.js +15 -0
  21. package/public/streamo/Signer.js +91 -0
  22. package/public/streamo/Streamo.js +392 -0
  23. package/public/streamo/Streamo.test.js +205 -0
  24. package/public/streamo/archiveSync.js +62 -0
  25. package/public/streamo/chat-cli.js +122 -0
  26. package/public/streamo/chat-server.js +60 -0
  27. package/public/streamo/codecs.js +400 -0
  28. package/public/streamo/fileSync.js +238 -0
  29. package/public/streamo/h.js +202 -0
  30. package/public/streamo/h.mount.test.js +67 -0
  31. package/public/streamo/h.test.js +121 -0
  32. package/public/streamo/mount.js +248 -0
  33. package/public/streamo/originSync.js +60 -0
  34. package/public/streamo/outletSync.js +105 -0
  35. package/public/streamo/registrySync.js +333 -0
  36. package/public/streamo/registrySync.test.js +373 -0
  37. package/public/streamo/s3Sync.js +99 -0
  38. package/public/streamo/stateFileSync.js +17 -0
  39. package/public/streamo/sync.test.js +98 -0
  40. package/public/streamo/utils/NestedSet.js +41 -0
  41. package/public/streamo/utils/Recaller.js +77 -0
  42. package/public/streamo/utils/mockDOM.js +113 -0
  43. package/public/streamo/utils/nextTick.js +22 -0
  44. package/public/streamo/utils/noble-secp256k1.js +602 -0
  45. package/public/streamo/utils/testing.js +90 -0
  46. package/public/streamo/utils.js +57 -0
  47. package/public/streamo/webSync.js +118 -0
  48. package/scripts/serve.js +15 -0
  49. 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
+ }