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

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,26 @@
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_ALG,
19
+ HEADER_PUB,
20
+ HEADER_CONTENT_TYPE,
21
+ HEADER_ACCEPT,
22
+ DEFAULT_ALG,
23
+ signAppendAuthor,
8
24
  signRequest,
9
25
  stableStringify
10
26
  } from "@drakkar.software/starfish-protocol";
@@ -27,6 +43,9 @@ var StarfishHttpError = class extends Error {
27
43
 
28
44
  // src/client.ts
29
45
  var APPEND_DEFAULT_FIELD = "items";
46
+ function stripPushPrefix(path) {
47
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
48
+ }
30
49
  function encodeCapAuth(cap) {
31
50
  const json = stableStringify(cap);
32
51
  if (typeof btoa === "function") {
@@ -38,6 +57,7 @@ function encodeCapAuth(cap) {
38
57
  }
39
58
  var StarfishClient = class {
40
59
  baseUrl;
60
+ namespace;
41
61
  capProvider;
42
62
  fetch;
43
63
  /**
@@ -47,6 +67,7 @@ var StarfishClient = class {
47
67
  plugins;
48
68
  constructor(options) {
49
69
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
70
+ this.namespace = options.namespace || void 0;
50
71
  this.capProvider = options.capProvider;
51
72
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
52
73
  this.plugins = options.plugins ? [...options.plugins] : [];
@@ -70,6 +91,20 @@ var StarfishClient = class {
70
91
  return "";
71
92
  }
72
93
  }
94
+ /**
95
+ * Rewrite a request path for the configured namespace. A no-op when no
96
+ * namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
97
+ * (the `/v1` protocol-version segment is part of the namespaced route, matching
98
+ * the Python client and the server's namespace mount).
99
+ *
100
+ * Applied to the path used for BOTH the signature and the URL so the canonical
101
+ * path the client signs equals the path the server reconstructs from the URL.
102
+ * Covers SDK-helper-built paths too — that's the point: a namespace-unaware
103
+ * helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
104
+ */
105
+ applyNamespace(path) {
106
+ return this.namespace ? `/v1/${this.namespace}${path}` : path;
107
+ }
73
108
  /**
74
109
  * Build auth headers for a request. When a `capProvider` is set, signs the
75
110
  * request with the device's Ed25519 private key and returns the v3 header
@@ -81,28 +116,57 @@ var StarfishClient = class {
81
116
  * The host bound into the signature is derived from `baseUrl` once per call.
82
117
  */
83
118
  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 {};
119
+ if (!this.capProvider) return {};
120
+ const capCtx = await this.capProvider.getCap();
121
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
122
+ }
123
+ /**
124
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
125
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
126
+ * reuse it for BOTH the author signature (over the element data) and the
127
+ * request signature (over the body), without redeeming the cap twice — a
128
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
129
+ * presenter` bind the server checks.
130
+ */
131
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
132
+ const { cap, devEdPrivHex, pubHex, presenterAlg } = capCtx;
133
+ const req = {
134
+ method,
135
+ pathAndQuery,
136
+ body,
137
+ host: this.signingHost()
138
+ };
139
+ const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
140
+ const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
141
+ alg: signAlg
142
+ });
143
+ const headers = {
144
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
145
+ [HEADER_SIG]: sig,
146
+ [HEADER_TS]: String(ts),
147
+ [HEADER_NONCE]: nonce,
148
+ [HEADER_ALG]: alg
149
+ };
150
+ if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
151
+ return headers;
152
+ }
153
+ /**
154
+ * Resolve the author public key to attach to a signed append: the redeemer's
155
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
156
+ * device/member cap. This is the SAME key that signs the request, so a server
157
+ * enforcing author proof can bind the stored element to its writer. Returns
158
+ * undefined only for a (malformed) cap with neither — the append then goes
159
+ * unsigned and a server requiring signatures rejects it.
160
+ */
161
+ appendAuthorKey(capCtx) {
162
+ const { cap, pubHex, presenterAlg } = capCtx;
163
+ const authorPubHex = pubHex ?? cap.sub;
164
+ if (authorPubHex === void 0) return null;
165
+ const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
166
+ return { authorPubHex, signAlg };
103
167
  }
104
168
  async pull(path, checkpointOrOptions) {
105
- let pathAndQuery = path;
169
+ let pathAndQuery = this.applyNamespace(path);
106
170
  let appendField;
107
171
  if (typeof checkpointOrOptions === "number") {
108
172
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
@@ -134,7 +198,7 @@ var StarfishClient = class {
134
198
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
135
199
  const res = await this.fetch(url, {
136
200
  method: "GET",
137
- headers: { Accept: "application/json", ...authHeaders }
201
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
138
202
  });
139
203
  if (!res.ok) {
140
204
  throw new StarfishHttpError(res.status, await res.text());
@@ -146,27 +210,78 @@ var StarfishClient = class {
146
210
  }
147
211
  return result;
148
212
  }
213
+ /**
214
+ * Pull several documents in one round-trip via `/batch/pull`. `collections` is
215
+ * the list of distinct collection names; `opts.params` supplies, per collection,
216
+ * an ARRAY of path-param sets — one per document to read — so the SAME collection
217
+ * can fan in many documents (e.g. many users' `profile`) in a single request.
218
+ * The server auto-fills the `{identity}` param from the authenticated caller for
219
+ * any set that omits it, so a self-doc collection needs no params. Returns a map
220
+ * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
221
+ * in request order. Honors the configured namespace.
222
+ *
223
+ * For the common "many docs of one collection" case prefer {@link batchPullMany}.
224
+ *
225
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
226
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
227
+ */
228
+ async batchPull(collections, opts = {}) {
229
+ const search = new URLSearchParams();
230
+ search.set("collections", collections.join(","));
231
+ if (opts.params && Object.keys(opts.params).length > 0) {
232
+ search.set("params", JSON.stringify(opts.params));
233
+ }
234
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
235
+ const url = `${this.baseUrl}${pathAndQuery}`;
236
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
237
+ const res = await this.fetch(url, {
238
+ method: "GET",
239
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
240
+ });
241
+ if (!res.ok) {
242
+ throw new StarfishHttpError(res.status, await res.text());
243
+ }
244
+ return await res.json();
245
+ }
246
+ /**
247
+ * Convenience over {@link batchPull} for reading MANY documents of ONE
248
+ * collection in a single round-trip: pass the per-document param-sets and get
249
+ * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
250
+ * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
251
+ * issues no request and returns `[]`.
252
+ */
253
+ async batchPullMany(collection, paramsList) {
254
+ if (paramsList.length === 0) return [];
255
+ const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
256
+ return res.collections[collection] ?? [];
257
+ }
149
258
  /**
150
259
  * Push synced data to the server.
151
260
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
152
261
  * @param data - The full document data to push
153
262
  * @param baseHash - Hash of the document this push is based on (null for first push)
154
263
  *
155
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
156
- * and are produced by `SyncManager` when a `signer` is configured.
264
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
265
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
266
+ * top-level body siblings of `data`, where the server verifies it.
157
267
  * @throws {ConflictError} if the server detects a hash mismatch (409)
158
268
  */
159
- async push(path, data, baseHash) {
269
+ async push(path, data, baseHash, author) {
160
270
  const body = JSON.stringify({
161
- data,
162
- baseHash
271
+ [DATA_FIELD]: data,
272
+ [BASE_HASH_FIELD]: baseHash,
273
+ ...author && {
274
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
275
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
276
+ }
163
277
  });
164
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
165
- const res = await this.fetch(`${this.baseUrl}${path}`, {
278
+ const sendPath = this.applyNamespace(path);
279
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
280
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
166
281
  method: "POST",
167
282
  headers: {
168
- "Content-Type": "application/json",
169
- Accept: "application/json",
283
+ [HEADER_CONTENT_TYPE]: "application/json",
284
+ [HEADER_ACCEPT]: "application/json",
170
285
  ...authHeaders
171
286
  },
172
287
  body
@@ -179,21 +294,77 @@ var StarfishClient = class {
179
294
  }
180
295
  return res.json();
181
296
  }
297
+ /**
298
+ * Append an element to an appendOnly (`by_timestamp`) collection.
299
+ *
300
+ * Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
301
+ * authorized append is always accepted. Each element is stored server-side as
302
+ * `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
303
+ *
304
+ * @param path - the push endpoint (e.g. "/push/events")
305
+ * @param data - the element payload. For a `delegated` collection, encrypt it
306
+ * first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
307
+ * server stores it opaquely and never reads it.
308
+ * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
309
+ * non-negative integer strictly greater than the latest stored element's ts
310
+ * (else the server responds 409). Omit to let the server assign one.
311
+ * @throws {StarfishHttpError} on a non-2xx response — e.g. 409
312
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
313
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
314
+ * cap is reached (partition by a path parameter for higher volume).
315
+ */
316
+ async append(path, data, opts = {}) {
317
+ const sendPath = this.applyNamespace(path);
318
+ const bodyObj = { [DATA_FIELD]: data };
319
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
320
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
321
+ if (capCtx) {
322
+ const authorKey = this.appendAuthorKey(capCtx);
323
+ if (authorKey) {
324
+ const documentKey = stripPushPrefix(path);
325
+ const { authorPubkey, authorSignature } = signAppendAuthor(
326
+ documentKey,
327
+ data,
328
+ authorKey.authorPubHex,
329
+ capCtx.devEdPrivHex,
330
+ authorKey.signAlg
331
+ );
332
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
333
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
334
+ }
335
+ }
336
+ const body = JSON.stringify(bodyObj);
337
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
338
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
339
+ method: "POST",
340
+ headers: {
341
+ [HEADER_CONTENT_TYPE]: "application/json",
342
+ [HEADER_ACCEPT]: "application/json",
343
+ ...authHeaders
344
+ },
345
+ body
346
+ });
347
+ if (!res.ok) {
348
+ throw new StarfishHttpError(res.status, await res.text());
349
+ }
350
+ return res.json();
351
+ }
182
352
  /**
183
353
  * Pull binary data from a blob collection.
184
354
  * Returns raw bytes with the content hash from the ETag header.
185
355
  */
186
356
  async pullBlob(path) {
187
- const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
188
- const res = await this.fetch(`${this.baseUrl}${path}`, {
357
+ const sendPath = this.applyNamespace(path);
358
+ const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
359
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
189
360
  method: "GET",
190
- headers: { Accept: "*/*", ...authHeaders }
361
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
191
362
  });
192
363
  if (!res.ok) {
193
364
  throw new StarfishHttpError(res.status, await res.text());
194
365
  }
195
366
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
196
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
367
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
197
368
  const data = await res.arrayBuffer();
198
369
  return { data, hash: etag, contentType };
199
370
  }
@@ -202,12 +373,13 @@ var StarfishClient = class {
202
373
  * Binary collections use last-write-wins (no conflict detection).
203
374
  */
204
375
  async pushBlob(path, data, contentType) {
205
- const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
206
- const res = await this.fetch(`${this.baseUrl}${path}`, {
376
+ const sendPath = this.applyNamespace(path);
377
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
378
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
207
379
  method: "POST",
208
380
  headers: {
209
- "Content-Type": contentType,
210
- Accept: "application/json",
381
+ [HEADER_CONTENT_TYPE]: contentType,
382
+ [HEADER_ACCEPT]: "application/json",
211
383
  ...authHeaders
212
384
  },
213
385
  body: data
@@ -220,7 +392,13 @@ var StarfishClient = class {
220
392
  };
221
393
 
222
394
  // src/sync.ts
223
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
395
+ import {
396
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
397
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
398
+ deepMerge,
399
+ docAuthorCanonicalInput,
400
+ getBase64
401
+ } from "@drakkar.software/starfish-protocol";
224
402
 
225
403
  // src/validate.ts
226
404
  var ValidationError = class extends Error {
@@ -332,23 +510,24 @@ var SyncManager = class {
332
510
  try {
333
511
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
334
512
  if (this.aborted) throw new AbortError();
335
- let payload = sealed;
513
+ let author;
336
514
  if (this.signer) {
337
515
  const { devEdPubHex, sign } = await this.signer.getSigner();
338
516
  if (this.aborted) throw new AbortError();
339
- const canonical = stableStringify2(sealed);
517
+ const documentKey = stripPushPrefix(this.pushPath);
518
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
340
519
  const sigBytes = await sign(new TextEncoder().encode(canonical));
341
520
  if (this.aborted) throw new AbortError();
342
- payload = {
343
- ...sealed,
344
- authorPubkey: devEdPubHex,
345
- authorSignature: getBase64().encode(sigBytes)
521
+ author = {
522
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
523
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
346
524
  };
347
525
  }
348
526
  const result = await this.client.push(
349
527
  this.pushPath,
350
- payload,
351
- this.lastHash
528
+ sealed,
529
+ this.lastHash,
530
+ author
352
531
  );
353
532
  if (this.aborted) throw new AbortError();
354
533
  this.lastHash = result.hash;
@@ -390,6 +569,209 @@ var SyncManager = class {
390
569
  }
391
570
  };
392
571
 
572
+ // src/append-log.ts
573
+ import {
574
+ DEFAULT_ALG as DEFAULT_ALG2,
575
+ verifyAppendAuthor
576
+ } from "@drakkar.software/starfish-protocol";
577
+ var PULL_PATH_PREFIX = "/pull/";
578
+ function stripPullPrefix(path) {
579
+ return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
580
+ }
581
+ var AppendAuthorError = class extends Error {
582
+ constructor(ts) {
583
+ super(`append element author verification failed (ts=${ts})`);
584
+ this.ts = ts;
585
+ this.name = "AppendAuthorError";
586
+ }
587
+ };
588
+ function checkpointOf(items) {
589
+ let max = 0;
590
+ for (const it of items) if (it.ts > max) max = it.ts;
591
+ return max;
592
+ }
593
+ function withAuthor(ts, data, src) {
594
+ const out = { ts, data };
595
+ if (src.authorPubkey !== void 0) out.authorPubkey = src.authorPubkey;
596
+ if (src.authorSignature !== void 0) out.authorSignature = src.authorSignature;
597
+ return out;
598
+ }
599
+ var AppendLogCursor = class {
600
+ client;
601
+ pullPath;
602
+ appendField;
603
+ encryptor;
604
+ verifyAuthor;
605
+ onElementError;
606
+ persistEncrypted;
607
+ documentKey;
608
+ logger;
609
+ loggerName;
610
+ items;
611
+ lastCheckpoint;
612
+ /** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
613
+ * it so each runs against the checkpoint the previous one advanced — no two
614
+ * overlapping fetches read the same checkpoint and double-append a window. */
615
+ pullChain = Promise.resolve();
616
+ constructor(options) {
617
+ this.client = options.client;
618
+ this.pullPath = options.pullPath;
619
+ this.appendField = options.appendField ?? "items";
620
+ this.encryptor = options.encryptor;
621
+ this.verifyAuthor = options.verifyAuthor;
622
+ this.onElementError = options.onElementError ?? "throw";
623
+ this.persistEncrypted = options.persistEncrypted ?? false;
624
+ this.documentKey = stripPullPrefix(options.pullPath);
625
+ this.logger = options.logger;
626
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
627
+ const seed = options.initialItems ?? [];
628
+ const seedCheckpoint = checkpointOf(seed);
629
+ if (options.since != null) {
630
+ if (options.since < 0) throw new Error("since must be non-negative");
631
+ if (options.since < seedCheckpoint) {
632
+ throw new Error("since must be >= the max ts of initialItems");
633
+ }
634
+ this.lastCheckpoint = options.since;
635
+ } else {
636
+ this.lastCheckpoint = seedCheckpoint;
637
+ }
638
+ this.items = [...seed];
639
+ }
640
+ /**
641
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
642
+ * append them to the local log, and return ONLY the newly-fetched batch
643
+ * (decrypted when an `encryptor` is set).
644
+ *
645
+ * Atomic under `onElementError: "throw"` (the default): the batch is fully
646
+ * verified and decrypted into a local before any state mutation, so a
647
+ * verify/decrypt failure throws without advancing the checkpoint past elements
648
+ * that could never be re-fetched. Under `"skip"`, a failing element is dropped
649
+ * from the returned batch but the checkpoint still advances past it.
650
+ *
651
+ * Safe to call concurrently: overlapping calls are serialized internally, so
652
+ * each runs against the checkpoint the previous one advanced (no double-fetch
653
+ * of the same window). The next pull after one completes will pick up anything
654
+ * that arrived in between.
655
+ */
656
+ async pull() {
657
+ const run = this.pullChain.then(
658
+ () => this.doPull(),
659
+ () => this.doPull()
660
+ );
661
+ this.pullChain = run.then(
662
+ () => void 0,
663
+ () => void 0
664
+ );
665
+ return run;
666
+ }
667
+ async doPull() {
668
+ this.logger?.pullStart(this.loggerName);
669
+ const start = performance.now();
670
+ try {
671
+ const since = this.lastCheckpoint;
672
+ const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField };
673
+ const raw = await this.client.pull(this.pullPath, opts);
674
+ const batch = [];
675
+ const stored = [];
676
+ let maxTs = since;
677
+ let skipped = 0;
678
+ for (const el of raw) {
679
+ if (since > 0 && el.ts <= since) continue;
680
+ if (el.ts > maxTs) maxTs = el.ts;
681
+ let decrypted = null;
682
+ try {
683
+ this.verifyOne(el);
684
+ const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
685
+ decrypted = withAuthor(el.ts, data, el);
686
+ } catch (err) {
687
+ if (this.onElementError !== "skip") throw err;
688
+ skipped++;
689
+ }
690
+ if (this.persistEncrypted) {
691
+ stored.push(withAuthor(el.ts, el.data, el));
692
+ } else if (decrypted) {
693
+ stored.push(decrypted);
694
+ }
695
+ if (decrypted) batch.push(decrypted);
696
+ }
697
+ this.items.push(...stored);
698
+ this.lastCheckpoint = maxTs;
699
+ this.logger?.pullSuccess(
700
+ this.loggerName,
701
+ Math.round(performance.now() - start),
702
+ skipped > 0 ? { skippedCount: skipped } : void 0
703
+ );
704
+ return batch;
705
+ } catch (err) {
706
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
707
+ throw err;
708
+ }
709
+ }
710
+ /** Verify a single element's author signature over its RAW (pre-decryption)
711
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
712
+ * verification is disabled. */
713
+ verifyOne(el) {
714
+ if (!this.verifyAuthor) return;
715
+ const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
716
+ const { authorPubkey, authorSignature } = el;
717
+ if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
718
+ if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
719
+ throw new AppendAuthorError(el.ts);
720
+ }
721
+ const ok = verifyAppendAuthor(
722
+ this.documentKey,
723
+ el.data,
724
+ authorPubkey,
725
+ authorSignature,
726
+ policy.alg ?? DEFAULT_ALG2
727
+ );
728
+ if (!ok) throw new AppendAuthorError(el.ts);
729
+ }
730
+ /** The full accumulated log (a shallow copy), in `ts` order. Under
731
+ * `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
732
+ * re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
733
+ getItems() {
734
+ return [...this.items];
735
+ }
736
+ /**
737
+ * The full accumulated log, DECRYPTED — for rendering warm-started history in
738
+ * `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
739
+ * `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
740
+ * cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
741
+ * elements are already plaintext/decrypted and are returned as-is.
742
+ */
743
+ async getDecryptedItems() {
744
+ const snapshot = [...this.items];
745
+ if (!this.encryptor || !this.persistEncrypted) return snapshot;
746
+ const out = [];
747
+ for (const el of snapshot) {
748
+ try {
749
+ this.verifyOne(el);
750
+ const data = await this.encryptor.decrypt(el.data);
751
+ out.push(withAuthor(el.ts, data, el));
752
+ } catch (err) {
753
+ if (this.onElementError !== "skip") throw err;
754
+ }
755
+ }
756
+ return out;
757
+ }
758
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
759
+ * when nothing has been pulled or seeded. */
760
+ getCheckpoint() {
761
+ return this.lastCheckpoint;
762
+ }
763
+ /** Restore the checkpoint without seeding items — for persistence layers that
764
+ * store only the checkpoint. Used to resume incrementally across restarts.
765
+ * Rejects a value below the max `ts` already held: rewinding would make the
766
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
767
+ setCheckpoint(ts) {
768
+ if (ts < checkpointOf(this.items)) {
769
+ throw new Error("checkpoint must be >= the max ts already held");
770
+ }
771
+ this.lastCheckpoint = ts;
772
+ }
773
+ };
774
+
393
775
  // src/index.ts
394
776
  import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
395
777
 
@@ -1175,6 +1557,29 @@ function createMobileLifecycle(store, deps, options = {}) {
1175
1557
  netUnsub?.();
1176
1558
  };
1177
1559
  }
1560
+ function createAppendLogMobileLifecycle(store, deps, options = {}) {
1561
+ const { pullOnForeground = true } = options;
1562
+ const appSub = deps.appState.addEventListener("change", (appState) => {
1563
+ if (appState === "active" && pullOnForeground) {
1564
+ const { online, loading } = store.getState();
1565
+ if (online && !loading) {
1566
+ store.getState().pull().catch((err) => {
1567
+ console.error("[Starfish] foreground log pull failed:", err);
1568
+ });
1569
+ }
1570
+ }
1571
+ });
1572
+ let netUnsub = null;
1573
+ if (deps.netInfo) {
1574
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
1575
+ store.getState().setOnline(!!isConnected);
1576
+ });
1577
+ }
1578
+ return () => {
1579
+ appSub.remove();
1580
+ netUnsub?.();
1581
+ };
1582
+ }
1178
1583
 
1179
1584
  // src/multi-store.ts
1180
1585
  function createMultiStoreSync(options) {
@@ -1227,6 +1632,8 @@ function createMultiStoreSync(options) {
1227
1632
  }
1228
1633
  export {
1229
1634
  AbortError,
1635
+ AppendAuthorError,
1636
+ AppendLogCursor,
1230
1637
  ConflictError,
1231
1638
  ENCRYPTED_KEY,
1232
1639
  SnapshotHistory,
@@ -1235,10 +1642,12 @@ export {
1235
1642
  SyncManager,
1236
1643
  ValidationError,
1237
1644
  buildRevocationList,
1645
+ checkpointOf,
1238
1646
  classifyError,
1239
1647
  computeHash,
1240
1648
  configurePlatform,
1241
1649
  consoleSyncLogger,
1650
+ createAppendLogMobileLifecycle,
1242
1651
  createDebouncedPush,
1243
1652
  createDebouncedSync,
1244
1653
  createDedupFetch,
@@ -1262,7 +1671,7 @@ export {
1262
1671
  registerBackgroundSync,
1263
1672
  registerServiceWorker,
1264
1673
  revocationListCanonicalSigningInput,
1265
- stableStringify3 as stableStringify,
1674
+ stableStringify2 as stableStringify,
1266
1675
  startAdaptivePolling,
1267
1676
  startPolling,
1268
1677
  timestampWinner,