@drakkar.software/starfish-client 2.3.0 → 3.0.0-alpha.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 (62) hide show
  1. package/README.md +219 -0
  2. package/dist/_crypto_helpers.d.ts +4 -0
  3. package/dist/bindings/zustand.d.ts +5 -4
  4. package/dist/bindings/zustand.js +125 -79
  5. package/dist/bindings/zustand.js.map +4 -4
  6. package/dist/cap-mint.d.ts +20 -0
  7. package/dist/cap-mint.js +12 -0
  8. package/dist/cap-mint.js.map +7 -0
  9. package/dist/client.d.ts +52 -3
  10. package/dist/config.d.ts +1 -4
  11. package/dist/directory.d.ts +9 -0
  12. package/dist/directory.js +24 -0
  13. package/dist/directory.js.map +7 -0
  14. package/dist/identity.d.ts +4 -82
  15. package/dist/identity.js +2 -354
  16. package/dist/identity.js.map +4 -4
  17. package/dist/index.d.ts +8 -10
  18. package/dist/index.js +131 -251
  19. package/dist/index.js.map +4 -4
  20. package/dist/keyring.d.ts +6 -0
  21. package/dist/keyring.js +26 -0
  22. package/dist/keyring.js.map +7 -0
  23. package/dist/pairing.d.ts +6 -0
  24. package/dist/pairing.js +26 -0
  25. package/dist/pairing.js.map +7 -0
  26. package/dist/recipients.d.ts +6 -0
  27. package/dist/recipients.js +16 -0
  28. package/dist/recipients.js.map +7 -0
  29. package/dist/sync.d.ts +32 -8
  30. package/dist/testing.d.ts +1 -1
  31. package/dist/testing.js +2 -2
  32. package/dist/testing.js.map +2 -2
  33. package/dist/types.d.ts +48 -9
  34. package/package.json +3 -12
  35. package/dist/background-sync.js +0 -29
  36. package/dist/bindings/suspense.js +0 -49
  37. package/dist/client.js +0 -112
  38. package/dist/config.js +0 -18
  39. package/dist/crypto.js +0 -49
  40. package/dist/debounced-sync.js +0 -120
  41. package/dist/dedup.js +0 -35
  42. package/dist/entitlements.js +0 -41
  43. package/dist/export.js +0 -115
  44. package/dist/group-crypto.d.ts +0 -111
  45. package/dist/group-crypto.js +0 -205
  46. package/dist/group-crypto.js.map +0 -7
  47. package/dist/hash.d.ts +0 -10
  48. package/dist/hash.js +0 -34
  49. package/dist/history.js +0 -61
  50. package/dist/logger.js +0 -80
  51. package/dist/migrate.js +0 -38
  52. package/dist/mobile-lifecycle.js +0 -55
  53. package/dist/multi-store.js +0 -92
  54. package/dist/platform.d.ts +0 -52
  55. package/dist/platform.js +0 -62
  56. package/dist/polling.js +0 -52
  57. package/dist/resolvers.js +0 -223
  58. package/dist/service-worker.js +0 -55
  59. package/dist/storage/indexeddb.js +0 -59
  60. package/dist/sync.js +0 -127
  61. package/dist/types.js +0 -18
  62. package/dist/validate.js +0 -28
package/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # @drakkar.software/starfish-client
2
+
3
+ TypeScript client SDK for [Starfish](../../../README.md) — browser, Node.js, and React Native. Pull/push documents with hash-based conflict detection, end-to-end multi-recipient encryption, and cap-cert authorization.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @drakkar.software/starfish-client @drakkar.software/starfish-protocol
9
+ ```
10
+
11
+ Optional state bindings: `npm install zustand` (or `@legendapp/state`).
12
+
13
+ ## What's in v3.0
14
+
15
+ Starfish 3.0 is a clean break from 2.x. The v2 `deriveCredentials` / `generatePassphrase` / Bearer-token `authProvider` / `signData` / `signatureVerifier` / `createEncryptor` / `group-crypto` surface is **deleted**. See [docs/migration/v2-to-v3.md](../../../docs/migration/v2-to-v3.md).
16
+
17
+ The v3 model in one sentence: a passphrase derives an Ed25519+X25519 **root identity**, which signs **capability certificates** for each device or member, and each authenticated request is itself Ed25519-signed under the cap's subject key.
18
+
19
+ ## Quickstart (v3)
20
+
21
+ ```ts
22
+ import {
23
+ StarfishClient,
24
+ SyncManager,
25
+ bootstrapRootIdentity,
26
+ createKeyringEncryptor,
27
+ type Keyring,
28
+ } from "@drakkar.software/starfish-client"
29
+
30
+ // 1. Derive root identity + self-signed cap-cert from a passphrase.
31
+ const creds = await bootstrapRootIdentity(passphrase)
32
+ // creds = { rootEdPub, userId, device: {edPriv,edPub,kemPriv,kemPub}, capCert }
33
+
34
+ // 2. Wire StarfishClient to a CapProvider — every request is signed.
35
+ const client = new StarfishClient({
36
+ baseUrl: "https://api.example.com/v1",
37
+ capProvider: {
38
+ getCap: async () => ({ cap: creds.capCert, devEdPrivHex: creds.device.edPriv }),
39
+ },
40
+ })
41
+
42
+ // 3. (Delegated only) build an encryptor from the collection's keyring.
43
+ const keyring = (await client.pull(`/pull/notes/_keyring`)).data as Keyring
44
+ const encryptor = await createKeyringEncryptor(
45
+ keyring,
46
+ { kemPubHex: creds.device.kemPub, kemPrivHex: creds.device.kemPriv },
47
+ { trustedAdders: [creds.rootEdPub] }, // required — pubkey(s) you trust to grant access
48
+ )
49
+
50
+ // 4. Sync with optional per-push author signature.
51
+ const sync = new SyncManager({
52
+ client,
53
+ pullPath: `/pull/notes/${creds.userId}`,
54
+ pushPath: `/push/notes/${creds.userId}`,
55
+ encryptor,
56
+ signer: {
57
+ getSigner: async () => ({
58
+ devEdPubHex: creds.device.edPub,
59
+ sign: async (bytes) => ed25519Sign(creds.device.edPriv, bytes),
60
+ }),
61
+ },
62
+ })
63
+
64
+ await sync.push({ items: ["note 1"] }) // sealed, signed, hash-checked
65
+ await sync.pull() // decrypted plaintext
66
+ ```
67
+
68
+ ## Identity & key derivation
69
+
70
+ ```ts
71
+ import { bootstrapRootIdentity, deriveRootIdentity } from "@drakkar.software/starfish-client"
72
+ ```
73
+
74
+ - `deriveRootIdentity(passphrase)` — passphrase → `{ userId, keys: {edPriv, edPub, kemPriv, kemPub} }`. Pure derivation, no cap-cert.
75
+ - `bootstrapRootIdentity(passphrase)` — same derivation plus a self-signed `kind: "device"` full-scope cap-cert. Use this on the first device.
76
+
77
+ `userId = sha256(rootEdPub)[0:32]`. Two independent HKDF derivations (signing vs KEM) give full domain separation.
78
+
79
+ Details: [docs/ts/client/11-identity-key-derivation.md](../../../docs/ts/client/11-identity-key-derivation.md).
80
+
81
+ ## Cap-cert minting
82
+
83
+ ```ts
84
+ import { mintDeviceCap, mintMemberCap, scopes } from "@drakkar.software/starfish-client"
85
+ ```
86
+
87
+ - `mintDeviceCap(rootEdPriv, rootEdPub, subject, scope, opts?)` — subject acts as a proxy for the issuer (`auth.identity = issUserId`). Used for additional devices the user controls.
88
+ - `mintMemberCap(rootEdPriv, rootEdPub, subject, scope, opts?)` — subject keeps its own identity (`auth.identity = subUserId`); cap adds collection-scoped roles only.
89
+
90
+ ### Scope presets
91
+
92
+ | Preset | Ops | Paths |
93
+ |---|---|---|
94
+ | `scopes.readOnly(c)` | `read`, `list` | `c/*` |
95
+ | `scopes.writer(c)` | `read`, `list`, `write` | `c/*`, `!c/_keyring` (cannot grant new recipients) |
96
+ | `scopes.admin(c)` | `read`, `list`, `write` | `c/*` (can grant via the keyring) |
97
+ | `scopes.rootAll()` | all | `*` (device caps only) |
98
+
99
+ The `!` prefix in `paths` is a denylist; explicit deny beats wildcard allow.
100
+
101
+ Details: [docs/ts/client/25-capability-certs.md](../../../docs/ts/client/25-capability-certs.md).
102
+
103
+ ## Pairing additional devices
104
+
105
+ Three onboarding flows, all returning the same `DeviceCredentials` shape:
106
+
107
+ | Flow | Network | Helper |
108
+ |---|---|---|
109
+ | Bootstrap (first device) | none | `bootstrapRootIdentity(passphrase)` |
110
+ | QR (in-person, server-free) | none | `buildPairingQr` → `parsePairingQr` → `assemblePairingBundle` → `installPairingBundle` |
111
+ | Server-relay (remote, 6-digit code) | 2 TTL'd Starfish documents | `buildPairingRequest` → `readPairingRequest` → `buildPairingResponse` → `readPairingResponse` → `installPairingBundle` |
112
+
113
+ ```ts
114
+ import {
115
+ bootstrapRootIdentity,
116
+ buildPairingQr,
117
+ parsePairingQr,
118
+ assemblePairingBundle,
119
+ installPairingBundle,
120
+ buildPairingRequest,
121
+ readPairingRequest,
122
+ buildPairingResponse,
123
+ readPairingResponse,
124
+ deriveCodeKey,
125
+ } from "@drakkar.software/starfish-client"
126
+ ```
127
+
128
+ `deriveCodeKey(code, salt, iterations?)` is the PBKDF2-HMAC-SHA256 (200 000 iterations by default) used by the relay flow.
129
+
130
+ Full walkthroughs: [docs/ts/client/24-pairing.md](../../../docs/ts/client/24-pairing.md).
131
+
132
+ ## Multi-recipient delegated encryption
133
+
134
+ A `"delegated"` collection has data documents (opaque ciphertext) plus one **keyring document** at `<collection>/_keyring` that wraps the current Content Encryption Key (CEK) for each recipient via X25519 ECDH + HKDF + AES-256-GCM (HPKE-DHKEM style).
135
+
136
+ ### Low-level keyring API
137
+
138
+ ```ts
139
+ import {
140
+ createKeyring,
141
+ addRecipient, // low-level: append entry to an in-memory keyring
142
+ rotateEpoch,
143
+ wrapForRecipient,
144
+ unwrapFromEntry,
145
+ verifyEntrySignature,
146
+ createKeyringEncryptor,
147
+ type Keyring,
148
+ type KeyringEpoch,
149
+ type WrappedKeyEntry,
150
+ type KeyringEncryptor,
151
+ KEYRING_WRAP_SALT,
152
+ KEYRING_WRAP_INFO,
153
+ KEYRING_IV_BYTES,
154
+ } from "@drakkar.software/starfish-client"
155
+ ```
156
+
157
+ - `createKeyring(adder, recipients, cek?, addedAt?)` — first-time setup, generates a CEK if not provided.
158
+ - `addRecipient(keyring, adder, currentCek, recipientKemHex, addedAt?)` — appends one wrap entry to the current epoch.
159
+ - `rotateEpoch(keyring, adder, retainedRecipients, addedAt?)` — mints a fresh CEK in `currentEpoch + 1`, re-wraps for the retained set.
160
+ - `createKeyringEncryptor(keyring, deviceKem, { trustedAdders, minEpoch? })` — returns an `Encryptor` compatible with `SyncManager`. Encrypts under `currentEpoch`; decrypts any epoch the device has a wrap for. `trustedAdders` is **required** (throws without it); optional `minEpoch` rejects a rolled-back keyring below the last-seen epoch.
161
+
162
+ ### Collection-scoped recipient management
163
+
164
+ ```ts
165
+ import {
166
+ addCollectionRecipient, // adds + pushes the keyring back to the server
167
+ removeRecipient,
168
+ listRecipients,
169
+ currentEpoch,
170
+ keyringPathFor,
171
+ type RecipientRef,
172
+ type AdderKeys,
173
+ type ListedRecipient,
174
+ } from "@drakkar.software/starfish-client"
175
+ ```
176
+
177
+ These wrap the low-level helpers with HTTP I/O via `StarfishClient`: each operation pulls the keyring, mutates it, and pushes back with hash-checked optimistic concurrency. `addCollectionRecipient`, `removeRecipient`, and `listRecipients` all **require** a `trustedAdders` pin (they throw without one); `listRecipients` returns only provenance-verified entries.
178
+
179
+ Details + algorithm + threat model: [docs/ts/client/23-multi-recipient-delegated.md](../../../docs/ts/client/23-multi-recipient-delegated.md).
180
+
181
+ ## `StarfishClient`
182
+
183
+ ```ts
184
+ new StarfishClient({
185
+ baseUrl: "https://api.example.com/v1",
186
+ capProvider, // v3 — signs every request. Replaces v2 `auth`/`authProvider`.
187
+ fetch, // optional custom fetch
188
+ })
189
+ ```
190
+
191
+ `StarfishCapProvider` is a single-method protocol: `getCap(): Promise<{ cap: CapCert, devEdPrivHex: string }>`. Implementations are expected to cache. When a `capProvider` is set, every outgoing request carries `Authorization: Cap <base64(stableStringify(cap))>` plus `X-Starfish-Sig`, `X-Starfish-Ts`, `X-Starfish-Nonce`.
192
+
193
+ Omit `capProvider` for unauthenticated public reads.
194
+
195
+ ## `SyncManager`
196
+
197
+ ```ts
198
+ new SyncManager({
199
+ client, pullPath, pushPath,
200
+ encryptor, // typically createKeyringEncryptor(...)
201
+ signer, // SyncSigner — replaces v2 `signData`
202
+ onConflict, maxRetries, validate, logger,
203
+ })
204
+ ```
205
+
206
+ - `signer.getSigner()` returns `{ devEdPubHex, sign(payload) }`. When set, every push attaches `authorPubkey = cap.sub` and `authorSignature = base64(Ed25519(payload))` over the encrypted payload (without the author fields).
207
+ - `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
208
+
209
+ ## Other utilities
210
+
211
+ The package also re-exports the v2 ergonomics that survived intact: `consoleSyncLogger`, `noopSyncLogger`, `createMetricsCollector`, `createMigrator`, `createSchemaValidator`, `classifyError`, conflict resolvers (`createUnionMerge`, `createSoftDeleteResolver`, `timestampWinner`, `pruneTombstones`), `SnapshotHistory`, `startPolling`/`startAdaptivePolling`, `createDedupFetch`, `fetchServerConfig`, `pullEntitlements`, `createIndexedDBStorage`, `exportData`/`importData`, `createDebouncedSync`/`createDebouncedPush`, `createMultiStoreSync`, `createMobileLifecycle`, and the Zustand/Legend bindings via the `./zustand` and `./legend` subpaths.
212
+
213
+ See the root [README.md](../../../README.md) for the catalog and [docs/ts/client/](../../../docs/ts/client/) for in-depth guides.
214
+
215
+ ## Removed in v3.0
216
+
217
+ `deriveCredentials`, `generatePassphrase`, `buildInviteUrl`, `parseInviteUrl` (the v2 passphrase identity surface), `createEncryptor` and the `SyncManager` `encryptionSecret`/`encryptionSalt`/`encryptionInfo` options, `wrapGroupKey`, `createGroupKeyring`, `addGroupMember`, `rotateGroupKey`, `createGroupEncryptor`, the v2 `auth`/`authProvider` Bearer hook, the `signData` callback, and the `signatureVerifier` server hook are all gone. Code that imports any of them will fail to build against v3.
218
+
219
+ Migration runbook: [docs/migration/v2-to-v3.md](../../../docs/migration/v2-to-v3.md).
@@ -0,0 +1,4 @@
1
+ export declare function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, lengthBytes?: number): Promise<Uint8Array>;
2
+ export declare function bytesToHex(bytes: Uint8Array): string;
3
+ export declare function hexToBytes(hex: string): Uint8Array;
4
+ export declare function concat(...parts: Uint8Array[]): Uint8Array;
@@ -1,8 +1,9 @@
1
1
  import { type StoreApi } from "zustand/vanilla";
2
2
  import { type StateStorage } from "zustand/middleware";
3
3
  import type { DevtoolsOptions } from "zustand/middleware";
4
+ import type { Encryptor } from "@drakkar.software/starfish-protocol";
4
5
  import { SyncManager } from "../sync.js";
5
- import type { AuthProvider, ConflictResolver } from "../types.js";
6
+ import type { StarfishCapProvider, ConflictResolver } from "../types.js";
6
7
  import type { SyncLogger } from "../logger.js";
7
8
  import type { Validator } from "../validate.js";
8
9
  export interface StarfishState {
@@ -103,11 +104,11 @@ export declare function useConnectivity(store: StoreApi<StarfishStore>): void;
103
104
  export declare function useLastSynced(store: StoreApi<StarfishStore>): string;
104
105
  export interface SyncInitConfig {
105
106
  serverUrl: string;
106
- auth?: AuthProvider;
107
+ capProvider?: StarfishCapProvider;
107
108
  pullPath: string;
108
109
  pushPath: string;
109
- encryptionSecret?: string;
110
- encryptionSalt?: string;
110
+ /** Pre-built encryptor for E2E collections (build via `createKeyringEncryptor`). */
111
+ encryptor?: Encryptor;
111
112
  onConflict?: ConflictResolver;
112
113
  /** Called when pulled data arrives. Use to restore domain stores. */
113
114
  onData?: (data: Record<string, unknown>) => void;
@@ -228,6 +228,12 @@ var persist = persistImpl;
228
228
  // src/bindings/zustand.ts
229
229
  import { useEffect, useRef, useState, useCallback } from "react";
230
230
 
231
+ // src/client.ts
232
+ import {
233
+ signRequest,
234
+ stableStringify
235
+ } from "@drakkar.software/starfish-protocol";
236
+
231
237
  // src/types.ts
232
238
  var ConflictError = class extends Error {
233
239
  constructor() {
@@ -246,34 +252,109 @@ var StarfishHttpError = class extends Error {
246
252
 
247
253
  // src/client.ts
248
254
  var APPEND_DEFAULT_FIELD = "items";
255
+ function encodeCapAuth(cap) {
256
+ const json = stableStringify(cap);
257
+ if (typeof btoa === "function") {
258
+ return btoa(json);
259
+ }
260
+ const bufCtor = globalThis.Buffer;
261
+ if (bufCtor) return bufCtor.from(json, "utf-8").toString("base64");
262
+ throw new Error("No base64 encoder available");
263
+ }
249
264
  var StarfishClient = class {
250
265
  baseUrl;
251
- auth;
266
+ capProvider;
252
267
  fetch;
268
+ /**
269
+ * Installed client-side plugins. Currently stored as inert data; no
270
+ * hooks fire yet. Extensions can inspect this list if needed.
271
+ */
272
+ plugins;
253
273
  constructor(options) {
254
274
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
255
- this.auth = options.auth;
275
+ this.capProvider = options.capProvider;
256
276
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
277
+ this.plugins = options.plugins ? [...options.plugins] : [];
278
+ }
279
+ /**
280
+ * Resolve the host portion of the URL the client will send to. The host
281
+ * is folded into the signed canonical input as the `h` field so the
282
+ * server can refuse a signature that was minted against a different
283
+ * Starfish host (replay-across-servers defence).
284
+ *
285
+ * When `baseUrl` is relative — e.g. the consumer passed a custom `fetch`
286
+ * that resolves relative URLs in its own context — there is no parseable
287
+ * host; we return `""` so signing still proceeds. The server-side
288
+ * verifier will also reconstruct host from its inbound URL, so the
289
+ * empty-host case still verifies symmetrically when both sides agree.
290
+ */
291
+ signingHost() {
292
+ try {
293
+ return new URL(this.baseUrl).host;
294
+ } catch {
295
+ return "";
296
+ }
297
+ }
298
+ /**
299
+ * Build auth headers for a request. When a `capProvider` is set, signs the
300
+ * request with the device's Ed25519 private key and returns the v3 header
301
+ * set (`Authorization: Cap …`, `X-Starfish-Sig`, `X-Starfish-Ts`,
302
+ * `X-Starfish-Nonce`). Empty when no provider is configured (public reads).
303
+ *
304
+ * Body bytes signed MUST equal the bytes sent on the wire — callers pass
305
+ * the already-serialized body string here so signing and transmission agree.
306
+ * The host bound into the signature is derived from `baseUrl` once per call.
307
+ */
308
+ async buildAuthHeaders(method, pathAndQuery, body) {
309
+ if (this.capProvider) {
310
+ const { cap, devEdPrivHex } = await this.capProvider.getCap();
311
+ const req = {
312
+ method,
313
+ pathAndQuery,
314
+ body,
315
+ host: this.signingHost()
316
+ };
317
+ const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
318
+ return {
319
+ Authorization: `Cap ${encodeCapAuth(cap)}`,
320
+ "X-Starfish-Sig": sig,
321
+ "X-Starfish-Ts": String(ts),
322
+ "X-Starfish-Nonce": nonce
323
+ };
324
+ }
325
+ return {};
257
326
  }
258
327
  async pull(path, checkpointOrOptions) {
259
- let url = `${this.baseUrl}${path}`;
328
+ let pathAndQuery = path;
260
329
  let appendField;
261
330
  if (typeof checkpointOrOptions === "number") {
262
- if (checkpointOrOptions) url += `?checkpoint=${checkpointOrOptions}`;
331
+ if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
263
332
  } else if (checkpointOrOptions != null) {
264
- appendField = checkpointOrOptions.appendField ?? APPEND_DEFAULT_FIELD;
333
+ const opts = checkpointOrOptions;
334
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
265
335
  const params = new URLSearchParams();
266
- if (checkpointOrOptions.since != null) {
267
- if (checkpointOrOptions.since < 0) throw new Error("since must be non-negative");
268
- params.set("checkpoint", String(checkpointOrOptions.since));
269
- }
270
- if (checkpointOrOptions.last != null) {
271
- if (checkpointOrOptions.last < 0) throw new Error("last must be non-negative");
272
- params.set("last", String(checkpointOrOptions.last));
336
+ if (isPullOptions) {
337
+ if (opts.checkpoint != null && opts.checkpoint > 0) {
338
+ params.set("checkpoint", String(opts.checkpoint));
339
+ }
340
+ if (opts.withKeyring) {
341
+ params.set("withKeyring", "1");
342
+ }
343
+ } else {
344
+ appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
345
+ if (opts.since != null) {
346
+ if (opts.since < 0) throw new Error("since must be non-negative");
347
+ params.set("checkpoint", String(opts.since));
348
+ }
349
+ if (opts.last != null) {
350
+ if (opts.last < 0) throw new Error("last must be non-negative");
351
+ params.set("last", String(opts.last));
352
+ }
273
353
  }
274
- if (params.size > 0) url += `?${params.toString()}`;
354
+ if (params.size > 0) pathAndQuery += `?${params.toString()}`;
275
355
  }
276
- const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
356
+ const url = `${this.baseUrl}${pathAndQuery}`;
357
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
277
358
  const res = await this.fetch(url, {
278
359
  method: "GET",
279
360
  headers: { Accept: "application/json", ...authHeaders }
@@ -293,16 +374,17 @@ var StarfishClient = class {
293
374
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
294
375
  * @param data - The full document data to push
295
376
  * @param baseHash - Hash of the document this push is based on (null for first push)
296
- * @param authorSignature - Optional author signature for provenance
377
+ *
378
+ * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
379
+ * and are produced by `SyncManager` when a `signer` is configured.
297
380
  * @throws {ConflictError} if the server detects a hash mismatch (409)
298
381
  */
299
- async push(path, data, baseHash, authorSignature) {
382
+ async push(path, data, baseHash) {
300
383
  const body = JSON.stringify({
301
384
  data,
302
- baseHash,
303
- ...authorSignature && { authorSignature }
385
+ baseHash
304
386
  });
305
- const authHeaders = this.auth ? await this.auth({ method: "POST", path, body }) : {};
387
+ const authHeaders = await this.buildAuthHeaders("POST", path, body);
306
388
  const res = await this.fetch(`${this.baseUrl}${path}`, {
307
389
  method: "POST",
308
390
  headers: {
@@ -325,7 +407,7 @@ var StarfishClient = class {
325
407
  * Returns raw bytes with the content hash from the ETag header.
326
408
  */
327
409
  async pullBlob(path) {
328
- const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
410
+ const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
329
411
  const res = await this.fetch(`${this.baseUrl}${path}`, {
330
412
  method: "GET",
331
413
  headers: { Accept: "*/*", ...authHeaders }
@@ -343,7 +425,7 @@ var StarfishClient = class {
343
425
  * Binary collections use last-write-wins (no conflict detection).
344
426
  */
345
427
  async pushBlob(path, data, contentType) {
346
- const authHeaders = this.auth ? await this.auth({ method: "POST", path, body: null }) : {};
428
+ const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
347
429
  const res = await this.fetch(`${this.baseUrl}${path}`, {
348
430
  method: "POST",
349
431
  headers: {
@@ -361,51 +443,7 @@ var StarfishClient = class {
361
443
  };
362
444
 
363
445
  // src/sync.ts
364
- import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
365
-
366
- // src/crypto.ts
367
- import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
368
- var ALGO = "AES-GCM";
369
- function createEncryptor(secret, salt, info = "starfish-e2e") {
370
- if (!secret) throw new Error("encryptionSecret must not be empty");
371
- if (!salt) throw new Error("encryptionSalt must not be empty");
372
- const keyPromise = deriveKey(secret, salt, info);
373
- return {
374
- async encrypt(data) {
375
- const key = await keyPromise;
376
- const c = getCrypto();
377
- const b64 = getBase64();
378
- const plaintext = new TextEncoder().encode(JSON.stringify(data));
379
- const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
380
- const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
381
- const combined = new Uint8Array(iv.length + ciphertext.byteLength);
382
- combined.set(iv);
383
- combined.set(new Uint8Array(ciphertext), iv.length);
384
- return { [ENCRYPTED_KEY]: b64.encode(combined) };
385
- },
386
- async decrypt(wrapper) {
387
- const encoded = wrapper[ENCRYPTED_KEY];
388
- if (typeof encoded !== "string") {
389
- throw new Error("Expected encrypted data but received unencrypted document");
390
- }
391
- const key = await keyPromise;
392
- const c = getCrypto();
393
- const b64 = getBase64();
394
- const combined = b64.decode(encoded);
395
- if (combined.length < IV_BYTES) {
396
- throw new Error("Encrypted data is too short");
397
- }
398
- const iv = combined.slice(0, IV_BYTES);
399
- const ciphertext = combined.slice(IV_BYTES);
400
- try {
401
- const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
402
- return JSON.parse(new TextDecoder().decode(plaintext));
403
- } catch (err) {
404
- throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
405
- }
406
- }
407
- };
408
- }
446
+ import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
409
447
 
410
448
  // src/validate.ts
411
449
  var ValidationError = class extends Error {
@@ -430,7 +468,7 @@ var SyncManager = class {
430
468
  onConflict;
431
469
  maxRetries;
432
470
  encryptor;
433
- signData;
471
+ signer;
434
472
  logger;
435
473
  loggerName;
436
474
  validate;
@@ -444,11 +482,11 @@ var SyncManager = class {
444
482
  this.pushPath = options.pushPath;
445
483
  this.onConflict = options.onConflict ?? deepMerge;
446
484
  this.maxRetries = options.maxRetries ?? 3;
447
- this.signData = options.signData;
485
+ this.signer = options.signer;
448
486
  this.logger = options.logger;
449
487
  this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
450
488
  this.validate = options.validate;
451
- this.encryptor = options.encryptor ?? (options.encryptionSecret && options.encryptionSalt ? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo) : null);
489
+ this.encryptor = options.encryptor ?? null;
452
490
  }
453
491
  abort() {
454
492
  this.aborted = true;
@@ -508,15 +546,25 @@ var SyncManager = class {
508
546
  let pendingData = data;
509
547
  while (attempt <= this.maxRetries) {
510
548
  try {
511
- const payload = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
512
- if (this.aborted) throw new AbortError();
513
- const sig = this.signData ? await this.signData(stableStringify(payload)) : void 0;
549
+ const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
514
550
  if (this.aborted) throw new AbortError();
551
+ let payload = sealed;
552
+ if (this.signer) {
553
+ const { devEdPubHex, sign } = await this.signer.getSigner();
554
+ if (this.aborted) throw new AbortError();
555
+ const canonical = stableStringify2(sealed);
556
+ const sigBytes = await sign(new TextEncoder().encode(canonical));
557
+ if (this.aborted) throw new AbortError();
558
+ payload = {
559
+ ...sealed,
560
+ authorPubkey: devEdPubHex,
561
+ authorSignature: getBase64().encode(sigBytes)
562
+ };
563
+ }
515
564
  const result = await this.client.push(
516
565
  this.pushPath,
517
566
  payload,
518
- this.lastHash,
519
- sig
567
+ this.lastHash
520
568
  );
521
569
  if (this.aborted) throw new AbortError();
522
570
  this.lastHash = result.hash;
@@ -790,15 +838,14 @@ function useSyncInit(config) {
790
838
  }
791
839
  const client = new StarfishClient({
792
840
  baseUrl: config.serverUrl,
793
- auth: config.auth,
841
+ capProvider: config.capProvider,
794
842
  fetch: config.fetch
795
843
  });
796
844
  const syncManager = new SyncManager({
797
845
  client,
798
846
  pullPath: config.pullPath,
799
847
  pushPath: config.pushPath,
800
- encryptionSecret: config.encryptionSecret,
801
- encryptionSalt: config.encryptionSalt,
848
+ encryptor: config.encryptor,
802
849
  onConflict: config.onConflict,
803
850
  logger: config.logger,
804
851
  validate: config.validate
@@ -829,8 +876,7 @@ function useSyncInit(config) {
829
876
  config?.serverUrl,
830
877
  config?.pullPath,
831
878
  config?.pushPath,
832
- config?.encryptionSecret,
833
- config?.encryptionSalt,
879
+ config?.encryptor,
834
880
  config?.storeName
835
881
  ]);
836
882
  return store;