@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,24 @@
1
1
  // src/index.ts
2
2
  import { configurePlatform } from "@drakkar.software/starfish-protocol";
3
- import { stableStringify as stableStringify3, computeHash } from "@drakkar.software/starfish-protocol";
3
+ import { stableStringify as stableStringify2, computeHash } from "@drakkar.software/starfish-protocol";
4
4
  import { buildRevocationList, revocationListCanonicalSigningInput } from "@drakkar.software/starfish-protocol";
5
5
 
6
6
  // src/client.ts
7
7
  import {
8
+ AUTHOR_PUBKEY_FIELD,
9
+ AUTHOR_SIGNATURE_FIELD,
10
+ DATA_FIELD,
11
+ TS_FIELD,
12
+ BASE_HASH_FIELD,
13
+ PUSH_PATH_PREFIX,
14
+ HEADER_AUTHORIZATION,
15
+ HEADER_SIG,
16
+ HEADER_TS,
17
+ HEADER_NONCE,
18
+ HEADER_PUB,
19
+ HEADER_CONTENT_TYPE,
20
+ HEADER_ACCEPT,
21
+ signAppendAuthor,
8
22
  signRequest,
9
23
  stableStringify
10
24
  } from "@drakkar.software/starfish-protocol";
@@ -27,6 +41,16 @@ var StarfishHttpError = class extends Error {
27
41
 
28
42
  // src/client.ts
29
43
  var APPEND_DEFAULT_FIELD = "items";
44
+ function pullCacheKey(pathAndQuery) {
45
+ const q = pathAndQuery.indexOf("?");
46
+ return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
47
+ }
48
+ function pullWasFromCache(result) {
49
+ return result.fromCache === true;
50
+ }
51
+ function stripPushPrefix(path) {
52
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
53
+ }
30
54
  function encodeCapAuth(cap) {
31
55
  const json = stableStringify(cap);
32
56
  if (typeof btoa === "function") {
@@ -38,8 +62,11 @@ function encodeCapAuth(cap) {
38
62
  }
39
63
  var StarfishClient = class {
40
64
  baseUrl;
65
+ namespace;
41
66
  capProvider;
42
67
  fetch;
68
+ cache;
69
+ cacheMaxAgeMs;
43
70
  /**
44
71
  * Installed client-side plugins. Currently stored as inert data; no
45
72
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -47,10 +74,22 @@ var StarfishClient = class {
47
74
  plugins;
48
75
  constructor(options) {
49
76
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
77
+ this.namespace = options.namespace || void 0;
50
78
  this.capProvider = options.capProvider;
51
79
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
80
+ this.cache = options.cache;
81
+ this.cacheMaxAgeMs = options.cacheMaxAgeMs;
52
82
  this.plugins = options.plugins ? [...options.plugins] : [];
53
83
  }
84
+ /**
85
+ * Mark a `PullResult` as having been served from the offline read-through
86
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
87
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
88
+ */
89
+ tagFromCache(result) {
90
+ Object.defineProperty(result, "fromCache", { value: true, enumerable: false });
91
+ return result;
92
+ }
54
93
  /**
55
94
  * Resolve the host portion of the URL the client will send to. The host
56
95
  * is folded into the signed canonical input as the `h` field so the
@@ -70,6 +109,20 @@ var StarfishClient = class {
70
109
  return "";
71
110
  }
72
111
  }
112
+ /**
113
+ * Rewrite a request path for the configured namespace. A no-op when no
114
+ * namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
115
+ * (the `/v1` protocol-version segment is part of the namespaced route, matching
116
+ * the Python client and the server's namespace mount).
117
+ *
118
+ * Applied to the path used for BOTH the signature and the URL so the canonical
119
+ * path the client signs equals the path the server reconstructs from the URL.
120
+ * Covers SDK-helper-built paths too — that's the point: a namespace-unaware
121
+ * helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
122
+ */
123
+ applyNamespace(path) {
124
+ return this.namespace ? `/v1/${this.namespace}${path}` : path;
125
+ }
73
126
  /**
74
127
  * Build auth headers for a request. When a `capProvider` is set, signs the
75
128
  * request with the device's Ed25519 private key and returns the v3 header
@@ -81,28 +134,52 @@ var StarfishClient = class {
81
134
  * The host bound into the signature is derived from `baseUrl` once per call.
82
135
  */
83
136
  async buildAuthHeaders(method, pathAndQuery, body) {
84
- if (this.capProvider) {
85
- const { cap, devEdPrivHex, pubHex } = await this.capProvider.getCap();
86
- const req = {
87
- method,
88
- pathAndQuery,
89
- body,
90
- host: this.signingHost()
91
- };
92
- const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
93
- const headers = {
94
- Authorization: `Cap ${encodeCapAuth(cap)}`,
95
- "X-Starfish-Sig": sig,
96
- "X-Starfish-Ts": String(ts),
97
- "X-Starfish-Nonce": nonce
98
- };
99
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
100
- return headers;
101
- }
102
- return {};
137
+ if (!this.capProvider) return {};
138
+ const capCtx = await this.capProvider.getCap();
139
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
140
+ }
141
+ /**
142
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
143
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
144
+ * reuse it for BOTH the author signature (over the element data) and the
145
+ * request signature (over the body), without redeeming the cap twice — a
146
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
147
+ * presenter` bind the server checks.
148
+ */
149
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
150
+ const { cap, devEdPrivHex, pubHex } = capCtx;
151
+ const req = {
152
+ method,
153
+ pathAndQuery,
154
+ body,
155
+ host: this.signingHost()
156
+ };
157
+ const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
158
+ const headers = {
159
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
160
+ [HEADER_SIG]: sig,
161
+ [HEADER_TS]: String(ts),
162
+ [HEADER_NONCE]: nonce
163
+ };
164
+ if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
165
+ return headers;
166
+ }
167
+ /**
168
+ * Resolve the author public key to attach to a signed append: the redeemer's
169
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
170
+ * device/member cap. This is the SAME key that signs the request, so a server
171
+ * enforcing author proof can bind the stored element to its writer. Returns
172
+ * undefined only for a (malformed) cap with neither — the append then goes
173
+ * unsigned and a server requiring signatures rejects it.
174
+ */
175
+ appendAuthorKey(capCtx) {
176
+ const { cap, pubHex } = capCtx;
177
+ const authorPubHex = pubHex ?? cap.sub;
178
+ if (authorPubHex === void 0) return null;
179
+ return { authorPubHex };
103
180
  }
104
181
  async pull(path, checkpointOrOptions) {
105
- let pathAndQuery = path;
182
+ let pathAndQuery = this.applyNamespace(path);
106
183
  let appendField;
107
184
  if (typeof checkpointOrOptions === "number") {
108
185
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
@@ -119,23 +196,43 @@ var StarfishClient = class {
119
196
  }
120
197
  } else {
121
198
  appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
199
+ if (opts.full && (opts.since != null || opts.limit != null || opts.last != null)) {
200
+ throw new Error("full cannot be combined with since, limit, or last");
201
+ }
122
202
  if (opts.since != null) {
123
203
  if (opts.since < 0) throw new Error("since must be non-negative");
124
204
  params.set("checkpoint", String(opts.since));
125
205
  }
206
+ if (opts.limit != null) {
207
+ if (opts.limit < 0) throw new Error("limit must be non-negative");
208
+ params.set("limit", String(opts.limit));
209
+ }
126
210
  if (opts.last != null) {
127
211
  if (opts.last < 0) throw new Error("last must be non-negative");
128
212
  params.set("last", String(opts.last));
129
213
  }
214
+ if (opts.full) {
215
+ params.set("full", "true");
216
+ }
130
217
  }
131
218
  if (params.size > 0) pathAndQuery += `?${params.toString()}`;
132
219
  }
133
220
  const url = `${this.baseUrl}${pathAndQuery}`;
134
221
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
135
- const res = await this.fetch(url, {
136
- method: "GET",
137
- headers: { Accept: "application/json", ...authHeaders }
138
- });
222
+ const cacheKey = this.cache && appendField === void 0 ? pullCacheKey(pathAndQuery) : void 0;
223
+ let res;
224
+ try {
225
+ res = await this.fetch(url, {
226
+ method: "GET",
227
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
228
+ });
229
+ } catch (err) {
230
+ if (cacheKey) {
231
+ const cached = await this.readCache(cacheKey);
232
+ if (cached) return cached;
233
+ }
234
+ throw err;
235
+ }
139
236
  if (!res.ok) {
140
237
  throw new StarfishHttpError(res.status, await res.text());
141
238
  }
@@ -144,29 +241,118 @@ var StarfishClient = class {
144
241
  const list = result.data?.[appendField];
145
242
  return Array.isArray(list) ? list : [];
146
243
  }
244
+ if (cacheKey) {
245
+ const snapshot = {
246
+ data: result.data,
247
+ hash: result.hash,
248
+ timestamp: result.timestamp,
249
+ cachedAt: Date.now()
250
+ };
251
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
252
+ });
253
+ }
147
254
  return result;
148
255
  }
256
+ /**
257
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
258
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
259
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
260
+ * or null when no cache is configured / there's no entry. Namespacing matches
261
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
262
+ */
263
+ async peekCache(path) {
264
+ if (!this.cache) return null;
265
+ return this.readCache(pullCacheKey(this.applyNamespace(path)));
266
+ }
267
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
268
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
269
+ * must not break a pull, just miss). */
270
+ async readCache(cacheKey) {
271
+ try {
272
+ const raw = await this.cache.get(cacheKey);
273
+ if (!raw) return null;
274
+ const parsed = JSON.parse(raw);
275
+ if (!parsed || typeof parsed.hash !== "string") return null;
276
+ if (this.cacheMaxAgeMs != null && Date.now() - (parsed.cachedAt ?? 0) > this.cacheMaxAgeMs) {
277
+ return null;
278
+ }
279
+ return this.tagFromCache({ data: parsed.data ?? {}, hash: parsed.hash, timestamp: parsed.timestamp ?? 0 });
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+ /**
285
+ * Pull several documents in one round-trip via `/batch/pull`. `collections` is
286
+ * the list of distinct collection names; `opts.params` supplies, per collection,
287
+ * an ARRAY of path-param sets — one per document to read — so the SAME collection
288
+ * can fan in many documents (e.g. many users' `profile`) in a single request.
289
+ * The server auto-fills the `{identity}` param from the authenticated caller for
290
+ * any set that omits it, so a self-doc collection needs no params. Returns a map
291
+ * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
292
+ * in request order. Honors the configured namespace.
293
+ *
294
+ * For the common "many docs of one collection" case prefer {@link batchPullMany}.
295
+ *
296
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
297
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
298
+ */
299
+ async batchPull(collections, opts = {}) {
300
+ const search = new URLSearchParams();
301
+ search.set("collections", collections.join(","));
302
+ if (opts.params && Object.keys(opts.params).length > 0) {
303
+ search.set("params", JSON.stringify(opts.params));
304
+ }
305
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
306
+ const url = `${this.baseUrl}${pathAndQuery}`;
307
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
308
+ const res = await this.fetch(url, {
309
+ method: "GET",
310
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
311
+ });
312
+ if (!res.ok) {
313
+ throw new StarfishHttpError(res.status, await res.text());
314
+ }
315
+ return await res.json();
316
+ }
317
+ /**
318
+ * Convenience over {@link batchPull} for reading MANY documents of ONE
319
+ * collection in a single round-trip: pass the per-document param-sets and get
320
+ * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
321
+ * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
322
+ * issues no request and returns `[]`.
323
+ */
324
+ async batchPullMany(collection, paramsList) {
325
+ if (paramsList.length === 0) return [];
326
+ const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
327
+ return res.collections[collection] ?? [];
328
+ }
149
329
  /**
150
330
  * Push synced data to the server.
151
331
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
152
332
  * @param data - The full document data to push
153
333
  * @param baseHash - Hash of the document this push is based on (null for first push)
154
334
  *
155
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
156
- * and are produced by `SyncManager` when a `signer` is configured.
335
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
336
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
337
+ * top-level body siblings of `data`, where the server verifies it.
157
338
  * @throws {ConflictError} if the server detects a hash mismatch (409)
158
339
  */
159
- async push(path, data, baseHash) {
340
+ async push(path, data, baseHash, author) {
160
341
  const body = JSON.stringify({
161
- data,
162
- baseHash
342
+ [DATA_FIELD]: data,
343
+ [BASE_HASH_FIELD]: baseHash,
344
+ ...author && {
345
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
346
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
347
+ }
163
348
  });
164
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
165
- const res = await this.fetch(`${this.baseUrl}${path}`, {
349
+ const sendPath = this.applyNamespace(path);
350
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
351
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
166
352
  method: "POST",
167
353
  headers: {
168
- "Content-Type": "application/json",
169
- Accept: "application/json",
354
+ [HEADER_CONTENT_TYPE]: "application/json",
355
+ [HEADER_ACCEPT]: "application/json",
170
356
  ...authHeaders
171
357
  },
172
358
  body
@@ -193,19 +379,37 @@ var StarfishClient = class {
193
379
  * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
194
380
  * non-negative integer strictly greater than the latest stored element's ts
195
381
  * (else the server responds 409). Omit to let the server assign one.
196
- * @throws {StarfishHttpError} on a non-2xx response (e.g. 409 for a
197
- * non-monotonic timestamp).
382
+ * @throws {StarfishHttpError} on a non-2xx response e.g. 409
383
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
384
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
385
+ * cap is reached (partition by a path parameter for higher volume).
198
386
  */
199
387
  async append(path, data, opts = {}) {
200
- const bodyObj = { data };
201
- if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
388
+ const sendPath = this.applyNamespace(path);
389
+ const bodyObj = { [DATA_FIELD]: data };
390
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
391
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
392
+ if (capCtx) {
393
+ const authorKey = this.appendAuthorKey(capCtx);
394
+ if (authorKey) {
395
+ const documentKey = stripPushPrefix(path);
396
+ const { authorPubkey, authorSignature } = signAppendAuthor(
397
+ documentKey,
398
+ data,
399
+ authorKey.authorPubHex,
400
+ capCtx.devEdPrivHex
401
+ );
402
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
403
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
404
+ }
405
+ }
202
406
  const body = JSON.stringify(bodyObj);
203
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
204
- const res = await this.fetch(`${this.baseUrl}${path}`, {
407
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
408
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
205
409
  method: "POST",
206
410
  headers: {
207
- "Content-Type": "application/json",
208
- Accept: "application/json",
411
+ [HEADER_CONTENT_TYPE]: "application/json",
412
+ [HEADER_ACCEPT]: "application/json",
209
413
  ...authHeaders
210
414
  },
211
415
  body
@@ -220,16 +424,17 @@ var StarfishClient = class {
220
424
  * Returns raw bytes with the content hash from the ETag header.
221
425
  */
222
426
  async pullBlob(path) {
223
- const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
224
- const res = await this.fetch(`${this.baseUrl}${path}`, {
427
+ const sendPath = this.applyNamespace(path);
428
+ const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
429
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
225
430
  method: "GET",
226
- headers: { Accept: "*/*", ...authHeaders }
431
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
227
432
  });
228
433
  if (!res.ok) {
229
434
  throw new StarfishHttpError(res.status, await res.text());
230
435
  }
231
436
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
232
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
437
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
233
438
  const data = await res.arrayBuffer();
234
439
  return { data, hash: etag, contentType };
235
440
  }
@@ -238,12 +443,13 @@ var StarfishClient = class {
238
443
  * Binary collections use last-write-wins (no conflict detection).
239
444
  */
240
445
  async pushBlob(path, data, contentType) {
241
- const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
242
- const res = await this.fetch(`${this.baseUrl}${path}`, {
446
+ const sendPath = this.applyNamespace(path);
447
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
448
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
243
449
  method: "POST",
244
450
  headers: {
245
- "Content-Type": contentType,
246
- Accept: "application/json",
451
+ [HEADER_CONTENT_TYPE]: contentType,
452
+ [HEADER_ACCEPT]: "application/json",
247
453
  ...authHeaders
248
454
  },
249
455
  body: data
@@ -256,7 +462,13 @@ var StarfishClient = class {
256
462
  };
257
463
 
258
464
  // src/sync.ts
259
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
465
+ import {
466
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
467
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
468
+ deepMerge,
469
+ docAuthorCanonicalInput,
470
+ getBase64
471
+ } from "@drakkar.software/starfish-protocol";
260
472
 
261
473
  // src/validate.ts
262
474
  var ValidationError = class extends Error {
@@ -296,6 +508,7 @@ var SyncManager = class {
296
508
  lastCheckpoint = 0;
297
509
  localData = {};
298
510
  aborted = false;
511
+ lastFromCache = false;
299
512
  constructor(options) {
300
513
  this.client = options.client;
301
514
  this.pullPath = options.pullPath;
@@ -317,6 +530,18 @@ var SyncManager = class {
317
530
  getData() {
318
531
  return { ...this.localData };
319
532
  }
533
+ /**
534
+ * Merge a remote snapshot with local (optimistic) data using this manager's
535
+ * conflict resolver — the same resolver the push-conflict path uses. A plain
536
+ * {@link pull} overwrites the store's data with the server snapshot, which
537
+ * would drop un-pushed local writes (they live only in the store, never in
538
+ * `localData` until a push succeeds). The zustand binding calls this on pull
539
+ * while the store is dirty so those writes survive. `local` wins by the same
540
+ * rules as a push conflict.
541
+ */
542
+ resolve(local, remote) {
543
+ return this.onConflict(local, remote);
544
+ }
320
545
  getHash() {
321
546
  return this.lastHash;
322
547
  }
@@ -324,6 +549,40 @@ var SyncManager = class {
324
549
  setHash(hash) {
325
550
  this.lastHash = hash;
326
551
  }
552
+ /**
553
+ * Whether the most recent {@link pull} (or {@link seedFromCache}) was served
554
+ * from the client's offline read-through cache rather than a live server
555
+ * response. The binding surfaces this as a `stale` flag so the UI can show an
556
+ * offline indicator without treating a cache hit as "reachable". Reset to
557
+ * false by the next successful network pull.
558
+ */
559
+ getLastPullFromCache() {
560
+ return this.lastFromCache;
561
+ }
562
+ /**
563
+ * Cache-first paint: seed `localData` from the client's read-through cache
564
+ * WITHOUT touching the network, decrypting in memory for E2E collections.
565
+ * Returns whether anything was seeded (false on a miss, an expired entry, or
566
+ * a decrypt failure — e.g. keyring skew). Call once on store creation before
567
+ * the initial live {@link pull}, which then supersedes the seeded snapshot.
568
+ * Requires the client to have been built with a `cache`.
569
+ */
570
+ async seedFromCache() {
571
+ if (this.aborted) return false;
572
+ const cached = await this.client.peekCache(this.pullPath);
573
+ if (!cached) return false;
574
+ let data;
575
+ try {
576
+ data = this.encryptor ? await this.encryptor.decrypt(cached.data) : cached.data;
577
+ } catch {
578
+ return false;
579
+ }
580
+ if (this.aborted) return false;
581
+ this.localData = data;
582
+ this.lastHash = cached.hash;
583
+ this.lastFromCache = true;
584
+ return true;
585
+ }
327
586
  getCheckpoint() {
328
587
  return this.lastCheckpoint;
329
588
  }
@@ -334,6 +593,7 @@ var SyncManager = class {
334
593
  try {
335
594
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
336
595
  if (this.aborted) throw new AbortError();
596
+ this.lastFromCache = pullWasFromCache(result);
337
597
  if (this.encryptor) {
338
598
  const decrypted = await this.encryptor.decrypt(result.data);
339
599
  if (this.aborted) throw new AbortError();
@@ -368,23 +628,24 @@ var SyncManager = class {
368
628
  try {
369
629
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
370
630
  if (this.aborted) throw new AbortError();
371
- let payload = sealed;
631
+ let author;
372
632
  if (this.signer) {
373
633
  const { devEdPubHex, sign } = await this.signer.getSigner();
374
634
  if (this.aborted) throw new AbortError();
375
- const canonical = stableStringify2(sealed);
635
+ const documentKey = stripPushPrefix(this.pushPath);
636
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
376
637
  const sigBytes = await sign(new TextEncoder().encode(canonical));
377
638
  if (this.aborted) throw new AbortError();
378
- payload = {
379
- ...sealed,
380
- authorPubkey: devEdPubHex,
381
- authorSignature: getBase64().encode(sigBytes)
639
+ author = {
640
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
641
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
382
642
  };
383
643
  }
384
644
  const result = await this.client.push(
385
645
  this.pushPath,
386
- payload,
387
- this.lastHash
646
+ sealed,
647
+ this.lastHash,
648
+ author
388
649
  );
389
650
  if (this.aborted) throw new AbortError();
390
651
  this.lastHash = result.hash;
@@ -426,6 +687,208 @@ var SyncManager = class {
426
687
  }
427
688
  };
428
689
 
690
+ // src/append-log.ts
691
+ import {
692
+ verifyAppendAuthor
693
+ } from "@drakkar.software/starfish-protocol";
694
+ var PULL_PATH_PREFIX = "/pull/";
695
+ function stripPullPrefix(path) {
696
+ return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
697
+ }
698
+ var AppendAuthorError = class extends Error {
699
+ constructor(ts) {
700
+ super(`append element author verification failed (ts=${ts})`);
701
+ this.ts = ts;
702
+ this.name = "AppendAuthorError";
703
+ }
704
+ };
705
+ function checkpointOf(items) {
706
+ let max = 0;
707
+ for (const it of items) if (it.ts > max) max = it.ts;
708
+ return max;
709
+ }
710
+ function withAuthor(ts, data, src) {
711
+ const out = { ts, data };
712
+ if (src.authorPubkey !== void 0) out.authorPubkey = src.authorPubkey;
713
+ if (src.authorSignature !== void 0) out.authorSignature = src.authorSignature;
714
+ return out;
715
+ }
716
+ var AppendLogCursor = class {
717
+ client;
718
+ pullPath;
719
+ appendField;
720
+ encryptor;
721
+ verifyAuthor;
722
+ onElementError;
723
+ persistEncrypted;
724
+ documentKey;
725
+ logger;
726
+ loggerName;
727
+ items;
728
+ lastCheckpoint;
729
+ /** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
730
+ * it so each runs against the checkpoint the previous one advanced — no two
731
+ * overlapping fetches read the same checkpoint and double-append a window. */
732
+ pullChain = Promise.resolve();
733
+ constructor(options) {
734
+ this.client = options.client;
735
+ this.pullPath = options.pullPath;
736
+ this.appendField = options.appendField ?? "items";
737
+ this.encryptor = options.encryptor;
738
+ this.verifyAuthor = options.verifyAuthor;
739
+ this.onElementError = options.onElementError ?? "throw";
740
+ this.persistEncrypted = options.persistEncrypted ?? false;
741
+ this.documentKey = stripPullPrefix(options.pullPath);
742
+ this.logger = options.logger;
743
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
744
+ const seed = options.initialItems ?? [];
745
+ const seedCheckpoint = checkpointOf(seed);
746
+ if (options.since != null) {
747
+ if (options.since < 0) throw new Error("since must be non-negative");
748
+ if (options.since < seedCheckpoint) {
749
+ throw new Error("since must be >= the max ts of initialItems");
750
+ }
751
+ this.lastCheckpoint = options.since;
752
+ } else {
753
+ this.lastCheckpoint = seedCheckpoint;
754
+ }
755
+ this.items = [...seed];
756
+ }
757
+ /**
758
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
759
+ * append them to the local log, and return ONLY the newly-fetched batch
760
+ * (decrypted when an `encryptor` is set).
761
+ *
762
+ * Atomic under `onElementError: "throw"` (the default): the batch is fully
763
+ * verified and decrypted into a local before any state mutation, so a
764
+ * verify/decrypt failure throws without advancing the checkpoint past elements
765
+ * that could never be re-fetched. Under `"skip"`, a failing element is dropped
766
+ * from the returned batch but the checkpoint still advances past it.
767
+ *
768
+ * Safe to call concurrently: overlapping calls are serialized internally, so
769
+ * each runs against the checkpoint the previous one advanced (no double-fetch
770
+ * of the same window). The next pull after one completes will pick up anything
771
+ * that arrived in between.
772
+ */
773
+ async pull() {
774
+ const run = this.pullChain.then(
775
+ () => this.doPull(),
776
+ () => this.doPull()
777
+ );
778
+ this.pullChain = run.then(
779
+ () => void 0,
780
+ () => void 0
781
+ );
782
+ return run;
783
+ }
784
+ async doPull() {
785
+ this.logger?.pullStart(this.loggerName);
786
+ const start = performance.now();
787
+ try {
788
+ const since = this.lastCheckpoint;
789
+ const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField, full: true };
790
+ const raw = await this.client.pull(this.pullPath, opts);
791
+ const batch = [];
792
+ const stored = [];
793
+ let maxTs = since;
794
+ let skipped = 0;
795
+ for (const el of raw) {
796
+ if (since > 0 && el.ts <= since) continue;
797
+ if (el.ts > maxTs) maxTs = el.ts;
798
+ let decrypted = null;
799
+ try {
800
+ this.verifyOne(el);
801
+ const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
802
+ decrypted = withAuthor(el.ts, data, el);
803
+ } catch (err) {
804
+ if (this.onElementError !== "skip") throw err;
805
+ skipped++;
806
+ }
807
+ if (this.persistEncrypted) {
808
+ stored.push(withAuthor(el.ts, el.data, el));
809
+ } else if (decrypted) {
810
+ stored.push(decrypted);
811
+ }
812
+ if (decrypted) batch.push(decrypted);
813
+ }
814
+ this.items.push(...stored);
815
+ this.lastCheckpoint = maxTs;
816
+ this.logger?.pullSuccess(
817
+ this.loggerName,
818
+ Math.round(performance.now() - start),
819
+ skipped > 0 ? { skippedCount: skipped } : void 0
820
+ );
821
+ return batch;
822
+ } catch (err) {
823
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
824
+ throw err;
825
+ }
826
+ }
827
+ /** Verify a single element's author signature over its RAW (pre-decryption)
828
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
829
+ * verification is disabled. */
830
+ verifyOne(el) {
831
+ if (!this.verifyAuthor) return;
832
+ const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
833
+ const { authorPubkey, authorSignature } = el;
834
+ if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
835
+ if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
836
+ throw new AppendAuthorError(el.ts);
837
+ }
838
+ void policy;
839
+ const ok = verifyAppendAuthor(
840
+ this.documentKey,
841
+ el.data,
842
+ authorPubkey,
843
+ authorSignature
844
+ );
845
+ if (!ok) throw new AppendAuthorError(el.ts);
846
+ }
847
+ /** The full accumulated log (a shallow copy), in `ts` order. Under
848
+ * `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
849
+ * re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
850
+ getItems() {
851
+ return [...this.items];
852
+ }
853
+ /**
854
+ * The full accumulated log, DECRYPTED — for rendering warm-started history in
855
+ * `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
856
+ * `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
857
+ * cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
858
+ * elements are already plaintext/decrypted and are returned as-is.
859
+ */
860
+ async getDecryptedItems() {
861
+ const snapshot = [...this.items];
862
+ if (!this.encryptor || !this.persistEncrypted) return snapshot;
863
+ const out = [];
864
+ for (const el of snapshot) {
865
+ try {
866
+ this.verifyOne(el);
867
+ const data = await this.encryptor.decrypt(el.data);
868
+ out.push(withAuthor(el.ts, data, el));
869
+ } catch (err) {
870
+ if (this.onElementError !== "skip") throw err;
871
+ }
872
+ }
873
+ return out;
874
+ }
875
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
876
+ * when nothing has been pulled or seeded. */
877
+ getCheckpoint() {
878
+ return this.lastCheckpoint;
879
+ }
880
+ /** Restore the checkpoint without seeding items — for persistence layers that
881
+ * store only the checkpoint. Used to resume incrementally across restarts.
882
+ * Rejects a value below the max `ts` already held: rewinding would make the
883
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
884
+ setCheckpoint(ts) {
885
+ if (ts < checkpointOf(this.items)) {
886
+ throw new Error("checkpoint must be >= the max ts already held");
887
+ }
888
+ this.lastCheckpoint = ts;
889
+ }
890
+ };
891
+
429
892
  // src/index.ts
430
893
  import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
431
894
 
@@ -839,6 +1302,32 @@ function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
839
1302
  });
840
1303
  }
841
1304
 
1305
+ // src/mutate.ts
1306
+ async function mutateDoc(client, path, mutator, options = {}) {
1307
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
1308
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1309
+ let data = null;
1310
+ let hash = null;
1311
+ try {
1312
+ const res = await client.pull(path);
1313
+ data = res.data ?? null;
1314
+ hash = res.hash ?? null;
1315
+ } catch (err) {
1316
+ if (!(err instanceof StarfishHttpError) || err.status !== 404) throw err;
1317
+ }
1318
+ const next = mutator({ data, hash });
1319
+ if (next === null) return null;
1320
+ try {
1321
+ await client.push(path, next, hash);
1322
+ return next;
1323
+ } catch (err) {
1324
+ if (err instanceof ConflictError && attempt < maxAttempts - 1) continue;
1325
+ throw err;
1326
+ }
1327
+ }
1328
+ throw new ConflictError();
1329
+ }
1330
+
842
1331
  // src/config.ts
843
1332
  async function fetchServerConfig(baseUrl, options) {
844
1333
  const url = `${baseUrl.replace(/\/$/, "")}/config`;
@@ -1211,6 +1700,29 @@ function createMobileLifecycle(store, deps, options = {}) {
1211
1700
  netUnsub?.();
1212
1701
  };
1213
1702
  }
1703
+ function createAppendLogMobileLifecycle(store, deps, options = {}) {
1704
+ const { pullOnForeground = true } = options;
1705
+ const appSub = deps.appState.addEventListener("change", (appState) => {
1706
+ if (appState === "active" && pullOnForeground) {
1707
+ const { online, loading } = store.getState();
1708
+ if (online && !loading) {
1709
+ store.getState().pull().catch((err) => {
1710
+ console.error("[Starfish] foreground log pull failed:", err);
1711
+ });
1712
+ }
1713
+ }
1714
+ });
1715
+ let netUnsub = null;
1716
+ if (deps.netInfo) {
1717
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
1718
+ store.getState().setOnline(!!isConnected);
1719
+ });
1720
+ }
1721
+ return () => {
1722
+ appSub.remove();
1723
+ netUnsub?.();
1724
+ };
1725
+ }
1214
1726
 
1215
1727
  // src/multi-store.ts
1216
1728
  function createMultiStoreSync(options) {
@@ -1263,6 +1775,8 @@ function createMultiStoreSync(options) {
1263
1775
  }
1264
1776
  export {
1265
1777
  AbortError,
1778
+ AppendAuthorError,
1779
+ AppendLogCursor,
1266
1780
  ConflictError,
1267
1781
  ENCRYPTED_KEY,
1268
1782
  SnapshotHistory,
@@ -1271,10 +1785,12 @@ export {
1271
1785
  SyncManager,
1272
1786
  ValidationError,
1273
1787
  buildRevocationList,
1788
+ checkpointOf,
1274
1789
  classifyError,
1275
1790
  computeHash,
1276
1791
  configurePlatform,
1277
1792
  consoleSyncLogger,
1793
+ createAppendLogMobileLifecycle,
1278
1794
  createDebouncedPush,
1279
1795
  createDebouncedSync,
1280
1796
  createDedupFetch,
@@ -1293,12 +1809,14 @@ export {
1293
1809
  importData,
1294
1810
  isBackgroundSyncSupported,
1295
1811
  isServiceWorkerSupported,
1812
+ mutateDoc,
1296
1813
  noopSyncLogger,
1297
1814
  pruneTombstones,
1815
+ pullWasFromCache,
1298
1816
  registerBackgroundSync,
1299
1817
  registerServiceWorker,
1300
1818
  revocationListCanonicalSigningInput,
1301
- stableStringify3 as stableStringify,
1819
+ stableStringify2 as stableStringify,
1302
1820
  startAdaptivePolling,
1303
1821
  startPolling,
1304
1822
  timestampWinner,