@drakkar.software/starfish-client 2.3.0 → 3.0.0-alpha.1

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 +127 -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 +133 -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 +55 -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,111 @@ 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, pubHex } = 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
+ const headers = {
319
+ Authorization: `Cap ${encodeCapAuth(cap)}`,
320
+ "X-Starfish-Sig": sig,
321
+ "X-Starfish-Ts": String(ts),
322
+ "X-Starfish-Nonce": nonce
323
+ };
324
+ if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
325
+ return headers;
326
+ }
327
+ return {};
257
328
  }
258
329
  async pull(path, checkpointOrOptions) {
259
- let url = `${this.baseUrl}${path}`;
330
+ let pathAndQuery = path;
260
331
  let appendField;
261
332
  if (typeof checkpointOrOptions === "number") {
262
- if (checkpointOrOptions) url += `?checkpoint=${checkpointOrOptions}`;
333
+ if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
263
334
  } else if (checkpointOrOptions != null) {
264
- appendField = checkpointOrOptions.appendField ?? APPEND_DEFAULT_FIELD;
335
+ const opts = checkpointOrOptions;
336
+ const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
265
337
  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));
338
+ if (isPullOptions) {
339
+ if (opts.checkpoint != null && opts.checkpoint > 0) {
340
+ params.set("checkpoint", String(opts.checkpoint));
341
+ }
342
+ if (opts.withKeyring) {
343
+ params.set("withKeyring", "1");
344
+ }
345
+ } else {
346
+ appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
347
+ if (opts.since != null) {
348
+ if (opts.since < 0) throw new Error("since must be non-negative");
349
+ params.set("checkpoint", String(opts.since));
350
+ }
351
+ if (opts.last != null) {
352
+ if (opts.last < 0) throw new Error("last must be non-negative");
353
+ params.set("last", String(opts.last));
354
+ }
273
355
  }
274
- if (params.size > 0) url += `?${params.toString()}`;
356
+ if (params.size > 0) pathAndQuery += `?${params.toString()}`;
275
357
  }
276
- const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
358
+ const url = `${this.baseUrl}${pathAndQuery}`;
359
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
277
360
  const res = await this.fetch(url, {
278
361
  method: "GET",
279
362
  headers: { Accept: "application/json", ...authHeaders }
@@ -293,16 +376,17 @@ var StarfishClient = class {
293
376
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
294
377
  * @param data - The full document data to push
295
378
  * @param baseHash - Hash of the document this push is based on (null for first push)
296
- * @param authorSignature - Optional author signature for provenance
379
+ *
380
+ * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
381
+ * and are produced by `SyncManager` when a `signer` is configured.
297
382
  * @throws {ConflictError} if the server detects a hash mismatch (409)
298
383
  */
299
- async push(path, data, baseHash, authorSignature) {
384
+ async push(path, data, baseHash) {
300
385
  const body = JSON.stringify({
301
386
  data,
302
- baseHash,
303
- ...authorSignature && { authorSignature }
387
+ baseHash
304
388
  });
305
- const authHeaders = this.auth ? await this.auth({ method: "POST", path, body }) : {};
389
+ const authHeaders = await this.buildAuthHeaders("POST", path, body);
306
390
  const res = await this.fetch(`${this.baseUrl}${path}`, {
307
391
  method: "POST",
308
392
  headers: {
@@ -325,7 +409,7 @@ var StarfishClient = class {
325
409
  * Returns raw bytes with the content hash from the ETag header.
326
410
  */
327
411
  async pullBlob(path) {
328
- const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
412
+ const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
329
413
  const res = await this.fetch(`${this.baseUrl}${path}`, {
330
414
  method: "GET",
331
415
  headers: { Accept: "*/*", ...authHeaders }
@@ -343,7 +427,7 @@ var StarfishClient = class {
343
427
  * Binary collections use last-write-wins (no conflict detection).
344
428
  */
345
429
  async pushBlob(path, data, contentType) {
346
- const authHeaders = this.auth ? await this.auth({ method: "POST", path, body: null }) : {};
430
+ const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
347
431
  const res = await this.fetch(`${this.baseUrl}${path}`, {
348
432
  method: "POST",
349
433
  headers: {
@@ -361,51 +445,7 @@ var StarfishClient = class {
361
445
  };
362
446
 
363
447
  // 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
- }
448
+ import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
409
449
 
410
450
  // src/validate.ts
411
451
  var ValidationError = class extends Error {
@@ -430,7 +470,7 @@ var SyncManager = class {
430
470
  onConflict;
431
471
  maxRetries;
432
472
  encryptor;
433
- signData;
473
+ signer;
434
474
  logger;
435
475
  loggerName;
436
476
  validate;
@@ -444,11 +484,11 @@ var SyncManager = class {
444
484
  this.pushPath = options.pushPath;
445
485
  this.onConflict = options.onConflict ?? deepMerge;
446
486
  this.maxRetries = options.maxRetries ?? 3;
447
- this.signData = options.signData;
487
+ this.signer = options.signer;
448
488
  this.logger = options.logger;
449
489
  this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
450
490
  this.validate = options.validate;
451
- this.encryptor = options.encryptor ?? (options.encryptionSecret && options.encryptionSalt ? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo) : null);
491
+ this.encryptor = options.encryptor ?? null;
452
492
  }
453
493
  abort() {
454
494
  this.aborted = true;
@@ -508,15 +548,25 @@ var SyncManager = class {
508
548
  let pendingData = data;
509
549
  while (attempt <= this.maxRetries) {
510
550
  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;
551
+ const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
514
552
  if (this.aborted) throw new AbortError();
553
+ let payload = sealed;
554
+ if (this.signer) {
555
+ const { devEdPubHex, sign } = await this.signer.getSigner();
556
+ if (this.aborted) throw new AbortError();
557
+ const canonical = stableStringify2(sealed);
558
+ const sigBytes = await sign(new TextEncoder().encode(canonical));
559
+ if (this.aborted) throw new AbortError();
560
+ payload = {
561
+ ...sealed,
562
+ authorPubkey: devEdPubHex,
563
+ authorSignature: getBase64().encode(sigBytes)
564
+ };
565
+ }
515
566
  const result = await this.client.push(
516
567
  this.pushPath,
517
568
  payload,
518
- this.lastHash,
519
- sig
569
+ this.lastHash
520
570
  );
521
571
  if (this.aborted) throw new AbortError();
522
572
  this.lastHash = result.hash;
@@ -790,15 +840,14 @@ function useSyncInit(config) {
790
840
  }
791
841
  const client = new StarfishClient({
792
842
  baseUrl: config.serverUrl,
793
- auth: config.auth,
843
+ capProvider: config.capProvider,
794
844
  fetch: config.fetch
795
845
  });
796
846
  const syncManager = new SyncManager({
797
847
  client,
798
848
  pullPath: config.pullPath,
799
849
  pushPath: config.pushPath,
800
- encryptionSecret: config.encryptionSecret,
801
- encryptionSalt: config.encryptionSalt,
850
+ encryptor: config.encryptor,
802
851
  onConflict: config.onConflict,
803
852
  logger: config.logger,
804
853
  validate: config.validate
@@ -829,8 +878,7 @@ function useSyncInit(config) {
829
878
  config?.serverUrl,
830
879
  config?.pullPath,
831
880
  config?.pushPath,
832
- config?.encryptionSecret,
833
- config?.encryptionSalt,
881
+ config?.encryptor,
834
882
  config?.storeName
835
883
  ]);
836
884
  return store;