@hivemind-os/collective-core 0.2.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 (145) hide show
  1. package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
  2. package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
  3. package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
  4. package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
  5. package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
  6. package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
  7. package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
  8. package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
  9. package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
  10. package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
  11. package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
  12. package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
  13. package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
  14. package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
  15. package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
  16. package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
  17. package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
  18. package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
  19. package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
  20. package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
  21. package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
  22. package/.turbo/turbo-build.log +14 -0
  23. package/dist/index.d.ts +1675 -0
  24. package/dist/index.js +8006 -0
  25. package/dist/index.js.map +1 -0
  26. package/package.json +41 -0
  27. package/src/auth/device-flow.ts +108 -0
  28. package/src/auth/ed25519-provider.ts +43 -0
  29. package/src/auth/errors.ts +82 -0
  30. package/src/auth/evm-key.ts +55 -0
  31. package/src/auth/index.ts +8 -0
  32. package/src/auth/session-state.ts +25 -0
  33. package/src/auth/session-store.ts +510 -0
  34. package/src/auth/types.ts +81 -0
  35. package/src/auth/zklogin-provider.ts +902 -0
  36. package/src/blobstore/WALRUS_FINDINGS.md +284 -0
  37. package/src/blobstore/encrypted-store.ts +56 -0
  38. package/src/blobstore/fs-store.ts +91 -0
  39. package/src/blobstore/hybrid-store.ts +144 -0
  40. package/src/blobstore/index.ts +5 -0
  41. package/src/blobstore/interface.ts +33 -0
  42. package/src/blobstore/walrus-spike.ts +345 -0
  43. package/src/blobstore/walrus-store.ts +551 -0
  44. package/src/cache/agent-cache.ts +403 -0
  45. package/src/cache/index.ts +1 -0
  46. package/src/crypto/encryption.ts +152 -0
  47. package/src/crypto/index.ts +2 -0
  48. package/src/crypto/x25519.ts +41 -0
  49. package/src/dispute/client.ts +191 -0
  50. package/src/dispute/index.ts +1 -0
  51. package/src/events/index.ts +2 -0
  52. package/src/events/parser.ts +291 -0
  53. package/src/events/subscription.ts +131 -0
  54. package/src/evm/constants.ts +6 -0
  55. package/src/evm/index.ts +2 -0
  56. package/src/evm/wallet.ts +136 -0
  57. package/src/identity/did.ts +36 -0
  58. package/src/identity/index.ts +4 -0
  59. package/src/identity/keypair.ts +199 -0
  60. package/src/identity/signing.ts +28 -0
  61. package/src/index.ts +22 -0
  62. package/src/internal/parsing.ts +416 -0
  63. package/src/marketplace/client.ts +349 -0
  64. package/src/marketplace/index.ts +1 -0
  65. package/src/metering/hash-chain.ts +94 -0
  66. package/src/metering/index.ts +4 -0
  67. package/src/metering/meter.ts +80 -0
  68. package/src/metering/streaming.ts +196 -0
  69. package/src/metering/verification.ts +104 -0
  70. package/src/payment/index.ts +1 -0
  71. package/src/payment/rail-selector.ts +41 -0
  72. package/src/registry/client.ts +328 -0
  73. package/src/registry/index.ts +1 -0
  74. package/src/relay/consumer-client.ts +497 -0
  75. package/src/relay/index.ts +1 -0
  76. package/src/relay-registry/client.ts +295 -0
  77. package/src/relay-registry/discovery.ts +109 -0
  78. package/src/relay-registry/index.ts +2 -0
  79. package/src/reputation/anchor-client.ts +126 -0
  80. package/src/reputation/event-publisher.ts +67 -0
  81. package/src/reputation/index.ts +5 -0
  82. package/src/reputation/merkle.ts +79 -0
  83. package/src/reputation/score-calculator.ts +133 -0
  84. package/src/reputation/serialization.ts +37 -0
  85. package/src/reputation/store.ts +165 -0
  86. package/src/reputation/validation.ts +135 -0
  87. package/src/routing/circuit-breaker.ts +111 -0
  88. package/src/routing/fan-out.ts +266 -0
  89. package/src/routing/index.ts +4 -0
  90. package/src/routing/performance.ts +244 -0
  91. package/src/routing/selector.ts +225 -0
  92. package/src/spending/index.ts +1 -0
  93. package/src/spending/policy.ts +271 -0
  94. package/src/staking/client.ts +319 -0
  95. package/src/staking/index.ts +1 -0
  96. package/src/sui/client.ts +214 -0
  97. package/src/sui/index.ts +2 -0
  98. package/src/sui/tx-helpers.ts +1070 -0
  99. package/src/task/client.ts +215 -0
  100. package/src/task/index.ts +1 -0
  101. package/src/x402/client.ts +295 -0
  102. package/src/x402/index.ts +1 -0
  103. package/tests/auth/device-flow.test.ts +62 -0
  104. package/tests/auth/ed25519-provider.test.ts +24 -0
  105. package/tests/auth/evm-key.test.ts +31 -0
  106. package/tests/auth/session-store.test.ts +201 -0
  107. package/tests/auth/zklogin-provider.test.ts +366 -0
  108. package/tests/blobstore/encrypted-store.test.ts +78 -0
  109. package/tests/blobstore.test.ts +91 -0
  110. package/tests/cache.test.ts +124 -0
  111. package/tests/crypto/encryption.test.ts +70 -0
  112. package/tests/crypto/x25519.test.ts +47 -0
  113. package/tests/dispute/client.test.ts +238 -0
  114. package/tests/events.test.ts +202 -0
  115. package/tests/evm/wallet.test.ts +101 -0
  116. package/tests/hybrid-store.test.ts +121 -0
  117. package/tests/identity.test.ts +161 -0
  118. package/tests/marketplace.test.ts +308 -0
  119. package/tests/metering/hash-chain.test.ts +32 -0
  120. package/tests/metering/meter.test.ts +23 -0
  121. package/tests/metering/streaming.test.ts +52 -0
  122. package/tests/metering/verification.test.ts +27 -0
  123. package/tests/payment/rail-selector.test.ts +95 -0
  124. package/tests/registry.test.ts +183 -0
  125. package/tests/relay-consumer-client.test.ts +119 -0
  126. package/tests/relay-registry/client.test.ts +261 -0
  127. package/tests/reputation/event-publisher.test.ts +70 -0
  128. package/tests/reputation/merkle.test.ts +44 -0
  129. package/tests/reputation/score-calculator.test.ts +104 -0
  130. package/tests/reputation/store.test.ts +94 -0
  131. package/tests/routing/circuit-breaker.test.ts +45 -0
  132. package/tests/routing/fan-out.test.ts +123 -0
  133. package/tests/routing/performance.test.ts +49 -0
  134. package/tests/routing/selector.test.ts +114 -0
  135. package/tests/spending.test.ts +133 -0
  136. package/tests/staking/client.test.ts +286 -0
  137. package/tests/sui-client.test.ts +85 -0
  138. package/tests/task.test.ts +249 -0
  139. package/tests/tx-helpers.test.ts +70 -0
  140. package/tests/walrus-spike.test.ts +100 -0
  141. package/tests/walrus-store.test.ts +196 -0
  142. package/tests/x402/client.test.ts +116 -0
  143. package/tsconfig.json +9 -0
  144. package/tsup.config.ts +11 -0
  145. package/vitest.config.ts +8 -0
@@ -0,0 +1,284 @@
1
+ # Walrus spike findings
2
+
3
+ ## Recommendation
4
+
5
+ Use the Walrus HTTP publisher + aggregator API for M8, and keep the TypeScript SDK as a later option once the repo is ready to move to `@mysten/sui` 2.x.
6
+
7
+ ## What was validated
8
+
9
+ - Public Testnet endpoints are live and usable from Node.js on Windows:
10
+ - Publisher: `https://publisher.walrus-testnet.walrus.space`
11
+ - Aggregator: `https://aggregator.walrus-testnet.walrus.space`
12
+ - `PUT /v1/blobs?epochs=1` successfully stored blobs.
13
+ - `GET /v1/blobs/{blobId}` successfully fetched blobs.
14
+ - A 1 MiB round-trip succeeded with matching SHA-256.
15
+ - `@mysten/walrus@1.1.7` exists on npm.
16
+ - The SDK imported and read an existing blob successfully in an isolated Windows probe, but it peers on `@mysten/sui ^2.16.2` while this repo currently uses `@mysten/sui ^1.30.0`.
17
+
18
+ ## Best integration path
19
+
20
+ ### 1. HTTP API — recommended for M8
21
+
22
+ Why this is the best fit right now:
23
+
24
+ - Works with the repo's current dependency graph.
25
+ - Uses plain `fetch`, so it is easy to integrate into Node.js/TypeScript.
26
+ - Public Testnet publisher/aggregator endpoints already work.
27
+ - Much simpler operational model than direct SDK writes.
28
+
29
+ Important caveats:
30
+
31
+ - Public infrastructure has no SLA.
32
+ - Public publishers/aggregators default to a 10 MiB request limit.
33
+ - Renewal and deletion require ownership of the Walrus blob object, not just the blob ID.
34
+
35
+ ### 2. `@mysten/walrus` SDK — viable later, not the best first step here
36
+
37
+ Pros:
38
+
39
+ - Official SDK.
40
+ - Read path worked in an isolated Windows probe.
41
+ - Supports lower-level Walrus flows, quilts, upload relay support, and direct storage-node access.
42
+
43
+ Cons:
44
+
45
+ - Requires `@mysten/sui` 2.x, which is a major-version mismatch for this repo today.
46
+ - Direct reads and writes are request-heavy. The SDK README notes roughly ~2200 requests to write a blob and ~335 requests to read one when talking to storage nodes directly.
47
+ - Write flows require a signer with SUI + WAL and more setup than the HTTP publisher path.
48
+
49
+ ### 3. CLI — good fallback/admin tool, not primary Node integration
50
+
51
+ Good for:
52
+
53
+ - Manual debugging.
54
+ - Blob lifecycle management (`extend`, `delete`, attributes).
55
+ - Large uploads when public publisher limits are not enough.
56
+
57
+ Less ideal for M8 because it adds process-management overhead around a Node service.
58
+
59
+ ## Blob ID format
60
+
61
+ Observed live blob IDs looked like this:
62
+
63
+ - `a7wmADgKiwLDFnpjyu6NBTkCS-1zLblj3TfmXZWxnew`
64
+ - `Vs9WwzQbE1VIBp3QAEWKxnJSdy88m2x3JUC4qAmsQD8`
65
+
66
+ Findings:
67
+
68
+ - Format: URL-safe base64 without padding.
69
+ - Length: 43 characters.
70
+ - Decoded length: 32 bytes.
71
+ - Practical interpretation: a 256-bit identifier rendered as base64url.
72
+
73
+ ### Move mapping
74
+
75
+ The current Move `vector<u8>` type is appropriate.
76
+
77
+ Recommended mapping:
78
+
79
+ - Off-chain TypeScript: keep blob IDs as strings for ergonomics.
80
+ - On-chain Move: store the decoded 32-byte value in `vector<u8>`.
81
+
82
+ That means the Sui boundary should convert:
83
+
84
+ - `string (base64url)` -> `vector<u8>` by base64url decoding to 32 bytes
85
+ - `vector<u8>` -> `string (base64url)` by base64url encoding those 32 bytes
86
+
87
+ If the existing Move integration is currently storing UTF-8 bytes of the 43-character string, that still works mechanically, but it is less efficient and less semantically precise than storing the decoded 32-byte form.
88
+
89
+ ## Expiry, permanence, and renewal
90
+
91
+ Validated/documented behavior:
92
+
93
+ - Blob lifetime is controlled by the `epochs` query parameter.
94
+ - If omitted, blobs default to `1` epoch.
95
+ - Testnet epoch duration is documented as `1 day`.
96
+ - Max storage duration is documented as `53` epochs.
97
+ - `permanent=true` creates a non-deletable blob.
98
+ - `deletable=true` creates a blob that can be deleted before expiry.
99
+ - Newly stored blobs are deletable by default unless `permanent=true` is set.
100
+
101
+ Important nuance:
102
+
103
+ - `permanent` does **not** mean infinite retention.
104
+ - Blobs still expire when their purchased epochs run out.
105
+
106
+ Renewal findings:
107
+
108
+ - Walrus docs expose renewal through blob-object management flows (`walrus extend --blob-obj-id <BLOB_OBJECT_ID>`).
109
+ - I did **not** find a simple public HTTP `extend` endpoint in the docs used for this spike.
110
+ - Renewal requires the blob object ID / ownership context, not only the blob ID string.
111
+
112
+ Implication for M8:
113
+
114
+ - If HiveMind Collective only stores a blob ID string today, renewal is not enough by itself.
115
+ - If long-lived blobs matter, M8 should also persist the returned Walrus blob object ID and define who owns it.
116
+ - The HTTP `send_object_to=<SUI_ADDRESS>` option is likely important once wallet ownership is designed.
117
+
118
+ ## Size limits
119
+
120
+ Documented Walrus constraints:
121
+
122
+ - Approximate system max blob size: `13.6 GiB`.
123
+ - Quilt per-blob limit: approximately `4 GiB`.
124
+ - Public publisher/aggregator default request limit: `10 MiB`.
125
+
126
+ Spike result:
127
+
128
+ - 1 MiB blob upload/download worked against public Testnet.
129
+
130
+ Implication for M8:
131
+
132
+ - Small and medium task payloads are fine through public HTTP APIs.
133
+ - For larger payloads, use your own publisher, the CLI, or later a richer SDK/upload-relay flow.
134
+
135
+ ## Latency observations
136
+
137
+ Observed from this Windows Node.js environment against public Testnet infrastructure:
138
+
139
+ | Payload | Store time | Fetch time |
140
+ | --- | ---: | ---: |
141
+ | 14 bytes (`Hello, Walrus!`) | ~3.4s to ~6.4s | ~16ms to ~1.3s |
142
+ | 1 MiB | ~7.5s | ~1.3s |
143
+
144
+ Notes:
145
+
146
+ - Times varied between calls.
147
+ - Public infrastructure and testnet conditions likely dominate these numbers.
148
+ - M8 should assume retries/backoff are needed.
149
+
150
+ ## Windows compatibility
151
+
152
+ ### HTTP API
153
+
154
+ Confirmed working from Node.js on Windows.
155
+
156
+ ### TypeScript SDK
157
+
158
+ Confirmed in an isolated probe that on Windows:
159
+
160
+ - `pnpm add @mysten/walrus @mysten/sui@^2.16.2` succeeds.
161
+ - The SDK imports cleanly.
162
+ - `client.walrus.readBlob()` successfully read a live testnet blob.
163
+
164
+ Known concerns from docs/SDK README:
165
+
166
+ - Browser/bundler environments may need explicit WASM configuration.
167
+ - Node environments may need custom fetch timeout tuning.
168
+ - No Windows-specific blocker was observed in this spike.
169
+
170
+ ## Issues and concerns
171
+
172
+ 1. **Repo dependency mismatch**
173
+ - `@mysten/walrus` peers on `@mysten/sui ^2.16.2`.
174
+ - This repo currently depends on `@mysten/sui ^1.30.0`.
175
+ - Direct SDK adoption would likely force a Sui SDK upgrade.
176
+
177
+ 2. **Retention management needs more than blob IDs**
178
+ - Renewal/delete flows require blob object ownership.
179
+ - Blob ID alone is not enough for full lifecycle management.
180
+
181
+ 3. **Public infra is fine for the spike, not enough for production guarantees**
182
+ - No formal availability guarantees.
183
+ - Public request-size limits.
184
+ - Testnet may wipe without notice.
185
+
186
+ 4. **Deletion semantics differ from the current filesystem store**
187
+ - `BlobStore.delete()` cannot be cleanly implemented through the public HTTP path alone.
188
+ - The spike implementation throws a clear error instead of pretending deletion works.
189
+
190
+ ## M8 recommendation
191
+
192
+ 1. Implement M8 on top of the HTTP publisher/aggregator API first.
193
+ 2. Keep the underlying Walrus storage IDs as Walrus base64url strings, but wrap them in an HiveMind Collective blob reference (`walrus:<walrus-blob-id>:<sha256>`) when integrity metadata needs to travel with the task.
194
+ 3. Keep Move blob IDs as `vector<u8>` containing the decoded 32-byte value.
195
+ 4. Persist additional metadata for future lifecycle management:
196
+ - `blobId`
197
+ - `blobObjectId`
198
+ - `epochs`
199
+ - whether the blob was stored as permanent/deletable
200
+ 5. Add retry/backoff and endpoint failover before production use.
201
+ 6. Plan a separate upgrade track if you later want the full `@mysten/walrus` SDK in-repo.
202
+
203
+ ## Production configuration
204
+
205
+ HiveMind Collective now exposes three blob store modes in daemon config:
206
+
207
+ ```yaml
208
+ blobstore:
209
+ mode: filesystem | walrus | hybrid
210
+ filesystem:
211
+ dataDir: ~/.hivemind-os/collective/blobs
212
+ walrus:
213
+ publisherUrl: https://publisher.walrus-testnet.walrus.space
214
+ aggregatorUrl: https://aggregator.walrus-testnet.walrus.space
215
+ epochs: 5
216
+ maxBlobSize: 10485760
217
+ retryAttempts: 3
218
+ retryDelayMs: 1000
219
+ timeoutMs: 30000
220
+ hybrid:
221
+ preferWalrus: true
222
+ cacheLocally: true
223
+ ```
224
+
225
+ ### Mode selection
226
+
227
+ - `filesystem`: content-addressed local storage only.
228
+ - `walrus`: stores payloads in Walrus and returns HiveMind Collective blob references in the form `walrus:<walrus-blob-id>:<sha256>` so downstream fetches can verify integrity.
229
+ - `hybrid`: stores in Walrus first, falls back to filesystem if Walrus is unavailable, and can cache Walrus payloads locally for faster reads.
230
+
231
+ ### Switching between filesystem and Walrus
232
+
233
+ **Filesystem only**
234
+
235
+ ```yaml
236
+ blobstore:
237
+ mode: filesystem
238
+ filesystem:
239
+ dataDir: ~/.hivemind-os/collective/blobs
240
+ ```
241
+
242
+ **Walrus only**
243
+
244
+ ```yaml
245
+ blobstore:
246
+ mode: walrus
247
+ walrus:
248
+ publisherUrl: https://publisher.walrus-testnet.walrus.space
249
+ aggregatorUrl: https://aggregator.walrus-testnet.walrus.space
250
+ epochs: 5
251
+ ```
252
+
253
+ **Hybrid with local cache**
254
+
255
+ ```yaml
256
+ blobstore:
257
+ mode: hybrid
258
+ filesystem:
259
+ dataDir: ~/.hivemind-os/collective/blobs
260
+ walrus:
261
+ publisherUrl: https://publisher.walrus-testnet.walrus.space
262
+ aggregatorUrl: https://aggregator.walrus-testnet.walrus.space
263
+ epochs: 5
264
+ hybrid:
265
+ preferWalrus: true
266
+ cacheLocally: true
267
+ ```
268
+
269
+ ## Public endpoints
270
+
271
+ ### Testnet
272
+
273
+ - Publisher: `https://publisher.walrus-testnet.walrus.space`
274
+ - Aggregator: `https://aggregator.walrus-testnet.walrus.space`
275
+
276
+ ### Mainnet
277
+
278
+ - Aggregator: `https://aggregator.walrus-mainnet.walrus.space`
279
+ - Publisher: no anonymous public publisher is expected on mainnet; production deployments should run or provision an authenticated publisher endpoint.
280
+
281
+ For the current public operator lists, refer to the Walrus operator manifests:
282
+
283
+ - `https://docs.walrus.site/data/operator-list-testnet.json`
284
+ - `https://docs.walrus.site/data/operator-list-mainnet.json`
@@ -0,0 +1,56 @@
1
+ import type { BlobMetadata, BlobStore, StoredBlob } from './interface.js';
2
+ import { decryptFromSender, encryptForRecipient, parseEncryptedPayload, serializeEncryptedPayload } from '../crypto/encryption.js';
3
+ import type { X25519KeyPair } from '../crypto/x25519.js';
4
+
5
+ export class EncryptedBlobStore implements BlobStore {
6
+ constructor(
7
+ private readonly inner: BlobStore,
8
+ private readonly myKeyPair: X25519KeyPair,
9
+ ) {}
10
+
11
+ async storeEncrypted(
12
+ data: Uint8Array,
13
+ recipientPublicKey: Uint8Array,
14
+ ): Promise<{ blobId: string; hash: string }> {
15
+ const payload = await encryptForRecipient(data, this.myKeyPair.privateKey, recipientPublicKey);
16
+ const stored = await this.inner.store(serializeEncryptedPayload(payload));
17
+ return {
18
+ blobId: stored.blobId,
19
+ hash: stored.hash,
20
+ };
21
+ }
22
+
23
+ async fetchDecrypted(blobId: string): Promise<Uint8Array | null> {
24
+ const data = await this.inner.fetch(blobId);
25
+ if (!data) {
26
+ return null;
27
+ }
28
+
29
+ const payload = parseEncryptedPayload(data);
30
+ if (!payload) {
31
+ return data;
32
+ }
33
+
34
+ return await decryptFromSender(payload, this.myKeyPair.privateKey);
35
+ }
36
+
37
+ async store(data: Uint8Array): Promise<StoredBlob> {
38
+ return await this.inner.store(data);
39
+ }
40
+
41
+ async fetch(blobId: string): Promise<Uint8Array | null> {
42
+ return await this.inner.fetch(blobId);
43
+ }
44
+
45
+ async exists(blobId: string): Promise<boolean> {
46
+ return await this.inner.exists(blobId);
47
+ }
48
+
49
+ async delete(blobId: string): Promise<void> {
50
+ await this.inner.delete(blobId);
51
+ }
52
+
53
+ async getMetadata(blobId: string): Promise<BlobMetadata | null> {
54
+ return await this.inner.getMetadata?.(blobId) ?? null;
55
+ }
56
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { access, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ import { BlobIntegrityError, type BlobMetadata, type BlobStore, type StoredBlob } from './interface.js';
6
+
7
+ export class FilesystemBlobStore implements BlobStore {
8
+ constructor(private readonly baseDir: string) {}
9
+
10
+ async store(data: Uint8Array): Promise<StoredBlob> {
11
+ await mkdir(this.baseDir, { recursive: true });
12
+ const checksum = computeChecksum(data);
13
+ const blobPath = join(this.baseDir, checksum);
14
+ const storedAt = Date.now();
15
+
16
+ try {
17
+ await writeFile(blobPath, data, { flag: 'wx' });
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
20
+ throw error;
21
+ }
22
+ }
23
+
24
+ return {
25
+ blobId: checksum,
26
+ hash: checksum,
27
+ checksum,
28
+ contentHash: checksum,
29
+ size: data.byteLength,
30
+ storedAt,
31
+ };
32
+ }
33
+
34
+ async fetch(blobId: string): Promise<Uint8Array | null> {
35
+ try {
36
+ const data = new Uint8Array(await readFile(join(this.baseDir, blobId)));
37
+ const actualHash = computeChecksum(data);
38
+ if (actualHash !== blobId) {
39
+ throw new BlobIntegrityError(
40
+ `Filesystem blob ${blobId} failed SHA-256 verification.`,
41
+ blobId,
42
+ blobId,
43
+ actualHash,
44
+ );
45
+ }
46
+
47
+ return data;
48
+ } catch (error) {
49
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
50
+ return null;
51
+ }
52
+
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ async exists(blobId: string): Promise<boolean> {
58
+ try {
59
+ await access(join(this.baseDir, blobId));
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async getMetadata(blobId: string): Promise<BlobMetadata | null> {
67
+ try {
68
+ const blobStat = await stat(join(this.baseDir, blobId));
69
+ return {
70
+ blobId,
71
+ contentHash: blobId,
72
+ size: blobStat.size,
73
+ storedAt: Math.round(blobStat.birthtimeMs || blobStat.mtimeMs),
74
+ };
75
+ } catch (error) {
76
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
77
+ return null;
78
+ }
79
+
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ async delete(blobId: string): Promise<void> {
85
+ await rm(join(this.baseDir, blobId), { force: true });
86
+ }
87
+ }
88
+
89
+ function computeChecksum(data: Uint8Array): string {
90
+ return createHash('sha256').update(data).digest('hex');
91
+ }
@@ -0,0 +1,144 @@
1
+ import pino from 'pino';
2
+
3
+ import type { BlobMetadata, BlobStore, StoredBlob } from './interface.js';
4
+ import { FilesystemBlobStore } from './fs-store.js';
5
+ import { parseWalrusBlobReference, type WalrusBlobStore } from './walrus-store.js';
6
+
7
+ const logger = pino({ name: '@hivemind-os/collective-core:blobstore:hybrid' });
8
+
9
+ export interface HybridBlobStoreOptions {
10
+ preferWalrus?: boolean;
11
+ cacheLocally?: boolean;
12
+ }
13
+
14
+ export class HybridBlobStore implements BlobStore {
15
+ constructor(
16
+ private readonly walrus: WalrusBlobStore,
17
+ private readonly local: FilesystemBlobStore,
18
+ private readonly options: HybridBlobStoreOptions = {},
19
+ ) {}
20
+
21
+ async store(data: Uint8Array): Promise<StoredBlob> {
22
+ if (this.preferWalrus) {
23
+ try {
24
+ const stored = await this.walrus.store(data);
25
+ if (this.cacheLocally) {
26
+ await this.local.store(data);
27
+ }
28
+ return stored;
29
+ } catch (error) {
30
+ logger.warn({ err: error, size: data.byteLength }, 'Walrus store failed, falling back to filesystem');
31
+ return await this.local.store(data);
32
+ }
33
+ }
34
+
35
+ try {
36
+ return await this.local.store(data);
37
+ } catch (error) {
38
+ logger.warn({ err: error, size: data.byteLength }, 'Filesystem store failed, falling back to Walrus');
39
+ const stored = await this.walrus.store(data);
40
+ if (this.cacheLocally) {
41
+ await this.local.store(data);
42
+ }
43
+ return stored;
44
+ }
45
+ }
46
+
47
+ async fetch(blobId: string): Promise<Uint8Array | null> {
48
+ for (const localBlobId of getLocalBlobCandidates(blobId)) {
49
+ const cached = await this.local.fetch(localBlobId);
50
+ if (cached) {
51
+ return cached;
52
+ }
53
+ }
54
+
55
+ if (!isWalrusBlobId(blobId)) {
56
+ return null;
57
+ }
58
+
59
+ const remote = await this.walrus.fetch(blobId);
60
+ if (remote && this.cacheLocally) {
61
+ await this.local.store(remote);
62
+ }
63
+
64
+ return remote;
65
+ }
66
+
67
+ async exists(blobId: string): Promise<boolean> {
68
+ for (const localBlobId of getLocalBlobCandidates(blobId)) {
69
+ if (await this.local.exists(localBlobId)) {
70
+ return true;
71
+ }
72
+ }
73
+
74
+ return isWalrusBlobId(blobId) ? await this.walrus.exists(blobId) : false;
75
+ }
76
+
77
+ async getMetadata(blobId: string): Promise<BlobMetadata | null> {
78
+ if (isWalrusBlobId(blobId)) {
79
+ const walrusMetadata = await this.walrus.getMetadata(blobId);
80
+ if (walrusMetadata) {
81
+ return walrusMetadata;
82
+ }
83
+ }
84
+
85
+ for (const localBlobId of getLocalBlobCandidates(blobId)) {
86
+ const localMetadata = await this.local.getMetadata(localBlobId);
87
+ if (localMetadata) {
88
+ return localMetadata;
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ async delete(blobId: string): Promise<void> {
96
+ const localBlobIds = getLocalBlobCandidates(blobId);
97
+ await Promise.all(localBlobIds.map(async (localBlobId) => {
98
+ if (await this.local.exists(localBlobId)) {
99
+ await this.local.delete(localBlobId);
100
+ }
101
+ }));
102
+
103
+ const walrusLikeBlobId = isWalrusBlobId(blobId);
104
+ if (walrusLikeBlobId) {
105
+ await this.walrus.delete(blobId);
106
+ }
107
+ }
108
+
109
+ private get preferWalrus(): boolean {
110
+ return this.options.preferWalrus ?? true;
111
+ }
112
+
113
+ private get cacheLocally(): boolean {
114
+ return this.options.cacheLocally ?? true;
115
+ }
116
+ }
117
+
118
+ function getLocalBlobCandidates(blobId: string): string[] {
119
+ const candidates = new Set<string>();
120
+
121
+ if (/^[a-f0-9]{64}$/.test(blobId)) {
122
+ candidates.add(blobId);
123
+ }
124
+
125
+ try {
126
+ const reference = parseWalrusBlobReference(blobId);
127
+ if (reference.contentHash) {
128
+ candidates.add(reference.contentHash);
129
+ }
130
+ } catch {
131
+ // Non-Walrus blob ids are handled by the direct hash case above.
132
+ }
133
+
134
+ return [...candidates];
135
+ }
136
+
137
+ function isWalrusBlobId(blobId: string): boolean {
138
+ try {
139
+ parseWalrusBlobReference(blobId);
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
@@ -0,0 +1,5 @@
1
+ export * from './interface.js';
2
+ export * from './fs-store.js';
3
+ export * from './hybrid-store.js';
4
+ export * from './walrus-store.js';
5
+ export * from './encrypted-store.js';
@@ -0,0 +1,33 @@
1
+ export interface ContentAddressedBlob {
2
+ blobId: string;
3
+ contentHash: string;
4
+ size: number;
5
+ storedAt: number;
6
+ }
7
+
8
+ export interface StoredBlob extends ContentAddressedBlob {
9
+ hash: string;
10
+ checksum: string;
11
+ }
12
+
13
+ export type BlobMetadata = ContentAddressedBlob;
14
+
15
+ export class BlobIntegrityError extends Error {
16
+ constructor(
17
+ message: string,
18
+ readonly blobId: string,
19
+ readonly expectedHash: string,
20
+ readonly actualHash: string,
21
+ ) {
22
+ super(message);
23
+ this.name = 'BlobIntegrityError';
24
+ }
25
+ }
26
+
27
+ export interface BlobStore {
28
+ store(data: Uint8Array): Promise<StoredBlob>;
29
+ fetch(blobId: string): Promise<Uint8Array | null>;
30
+ exists(blobId: string): Promise<boolean>;
31
+ delete(blobId: string): Promise<void>;
32
+ getMetadata?(blobId: string): Promise<BlobMetadata | null>;
33
+ }