@drakkar.software/starfish-client 3.0.0-alpha.0 → 3.0.0-alpha.10

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.d.ts CHANGED
@@ -5,9 +5,11 @@ export { buildRevocationList, revocationListCanonicalSigningInput } from "@drakk
5
5
  export type { RevocationList, RevocationEntry, RevokedSubject, BuildRevocationListOpts, } from "@drakkar.software/starfish-protocol";
6
6
  export type { PullResult, PushSuccess, PullKeyringProjection } from "@drakkar.software/starfish-protocol";
7
7
  export { StarfishClient } from "./client.js";
8
- export type { BlobPullResult, BlobPushResult, AppendPullOptions, PullOptions } from "./client.js";
8
+ export type { BlobPullResult, BlobPushResult, AppendPullOptions, PullOptions, BatchPullOptions, BatchPullResult, BatchPullEntry, } from "./client.js";
9
9
  export { SyncManager, AbortError } from "./sync.js";
10
10
  export type { SyncManagerOptions, SyncSigner } from "./sync.js";
11
+ export { AppendLogCursor, AppendAuthorError, checkpointOf } from "./append-log.js";
12
+ export type { AppendLogCursorOptions, AppendElement, AuthorVerifier, ElementErrorPolicy } from "./append-log.js";
11
13
  export { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
12
14
  export type { Encryptor } from "@drakkar.software/starfish-protocol";
13
15
  export { ConflictError, StarfishHttpError, } from "./types.js";
@@ -40,8 +42,8 @@ export type { ServiceWorkerOptions } from "./service-worker.js";
40
42
  export { createSuspenseResource } from "./bindings/suspense.js";
41
43
  export { createDebouncedSync, createDebouncedPush } from "./debounced-sync.js";
42
44
  export type { DebouncedSyncOptions, DebouncedSync, DebouncedPushOptions, DebouncedPush } from "./debounced-sync.js";
43
- export { createMobileLifecycle } from "./mobile-lifecycle.js";
44
- export type { AppStateModule, NetInfoModule, MobileLifecycleDeps, MobileLifecycleOptions } from "./mobile-lifecycle.js";
45
+ export { createMobileLifecycle, createAppendLogMobileLifecycle } from "./mobile-lifecycle.js";
46
+ export type { AppStateModule, NetInfoModule, MobileLifecycleDeps, MobileLifecycleOptions, AppendLogLifecycleOptions } from "./mobile-lifecycle.js";
45
47
  export { createMultiStoreSync } from "./multi-store.js";
46
48
  export type { StoreSlice, BackupDocument, MultiStoreMigrationFn, MultiStoreSyncOptions, MultiStoreSync, } from "./multi-store.js";
47
49
  export type { AppendOnlyClientInfo } from "./config.js";
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,26 +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 } = 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
- return {
94
- Authorization: `Cap ${encodeCapAuth(cap)}`,
95
- "X-Starfish-Sig": sig,
96
- "X-Starfish-Ts": String(ts),
97
- "X-Starfish-Nonce": nonce
98
- };
99
- }
100
- 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 };
101
167
  }
102
168
  async pull(path, checkpointOrOptions) {
103
- let pathAndQuery = path;
169
+ let pathAndQuery = this.applyNamespace(path);
104
170
  let appendField;
105
171
  if (typeof checkpointOrOptions === "number") {
106
172
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
@@ -132,7 +198,7 @@ var StarfishClient = class {
132
198
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
133
199
  const res = await this.fetch(url, {
134
200
  method: "GET",
135
- headers: { Accept: "application/json", ...authHeaders }
201
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
136
202
  });
137
203
  if (!res.ok) {
138
204
  throw new StarfishHttpError(res.status, await res.text());
@@ -144,27 +210,62 @@ var StarfishClient = class {
144
210
  }
145
211
  return result;
146
212
  }
213
+ /**
214
+ * Pull several collections in one round-trip via `/batch/pull`. `collections`
215
+ * is the list of collection names; `opts.params` supplies path params per
216
+ * collection (serialized to a URL-encoded JSON `params` query). The server
217
+ * auto-fills the `{identity}` param from the authenticated caller, so per-user
218
+ * collections need no params. Returns a map of collection name → its pulled
219
+ * document or a per-collection `{ error }`. Honors the configured namespace.
220
+ *
221
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
222
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
223
+ */
224
+ async batchPull(collections, opts = {}) {
225
+ const search = new URLSearchParams();
226
+ search.set("collections", collections.join(","));
227
+ if (opts.params && Object.keys(opts.params).length > 0) {
228
+ search.set("params", JSON.stringify(opts.params));
229
+ }
230
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
231
+ const url = `${this.baseUrl}${pathAndQuery}`;
232
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
233
+ const res = await this.fetch(url, {
234
+ method: "GET",
235
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
236
+ });
237
+ if (!res.ok) {
238
+ throw new StarfishHttpError(res.status, await res.text());
239
+ }
240
+ return await res.json();
241
+ }
147
242
  /**
148
243
  * Push synced data to the server.
149
244
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
150
245
  * @param data - The full document data to push
151
246
  * @param baseHash - Hash of the document this push is based on (null for first push)
152
247
  *
153
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
154
- * and are produced by `SyncManager` when a `signer` is configured.
248
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
249
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
250
+ * top-level body siblings of `data`, where the server verifies it.
155
251
  * @throws {ConflictError} if the server detects a hash mismatch (409)
156
252
  */
157
- async push(path, data, baseHash) {
253
+ async push(path, data, baseHash, author) {
158
254
  const body = JSON.stringify({
159
- data,
160
- baseHash
255
+ [DATA_FIELD]: data,
256
+ [BASE_HASH_FIELD]: baseHash,
257
+ ...author && {
258
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
259
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
260
+ }
161
261
  });
162
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
163
- const res = await this.fetch(`${this.baseUrl}${path}`, {
262
+ const sendPath = this.applyNamespace(path);
263
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
264
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
164
265
  method: "POST",
165
266
  headers: {
166
- "Content-Type": "application/json",
167
- Accept: "application/json",
267
+ [HEADER_CONTENT_TYPE]: "application/json",
268
+ [HEADER_ACCEPT]: "application/json",
168
269
  ...authHeaders
169
270
  },
170
271
  body
@@ -177,21 +278,77 @@ var StarfishClient = class {
177
278
  }
178
279
  return res.json();
179
280
  }
281
+ /**
282
+ * Append an element to an appendOnly (`by_timestamp`) collection.
283
+ *
284
+ * Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
285
+ * authorized append is always accepted. Each element is stored server-side as
286
+ * `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
287
+ *
288
+ * @param path - the push endpoint (e.g. "/push/events")
289
+ * @param data - the element payload. For a `delegated` collection, encrypt it
290
+ * first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
291
+ * server stores it opaquely and never reads it.
292
+ * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
293
+ * non-negative integer strictly greater than the latest stored element's ts
294
+ * (else the server responds 409). Omit to let the server assign one.
295
+ * @throws {StarfishHttpError} on a non-2xx response — e.g. 409
296
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
297
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
298
+ * cap is reached (partition by a path parameter for higher volume).
299
+ */
300
+ async append(path, data, opts = {}) {
301
+ const sendPath = this.applyNamespace(path);
302
+ const bodyObj = { [DATA_FIELD]: data };
303
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
304
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
305
+ if (capCtx) {
306
+ const authorKey = this.appendAuthorKey(capCtx);
307
+ if (authorKey) {
308
+ const documentKey = stripPushPrefix(path);
309
+ const { authorPubkey, authorSignature } = signAppendAuthor(
310
+ documentKey,
311
+ data,
312
+ authorKey.authorPubHex,
313
+ capCtx.devEdPrivHex,
314
+ authorKey.signAlg
315
+ );
316
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
317
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
318
+ }
319
+ }
320
+ const body = JSON.stringify(bodyObj);
321
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
322
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
323
+ method: "POST",
324
+ headers: {
325
+ [HEADER_CONTENT_TYPE]: "application/json",
326
+ [HEADER_ACCEPT]: "application/json",
327
+ ...authHeaders
328
+ },
329
+ body
330
+ });
331
+ if (!res.ok) {
332
+ throw new StarfishHttpError(res.status, await res.text());
333
+ }
334
+ return res.json();
335
+ }
180
336
  /**
181
337
  * Pull binary data from a blob collection.
182
338
  * Returns raw bytes with the content hash from the ETag header.
183
339
  */
184
340
  async pullBlob(path) {
185
- const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
186
- const res = await this.fetch(`${this.baseUrl}${path}`, {
341
+ const sendPath = this.applyNamespace(path);
342
+ const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
343
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
187
344
  method: "GET",
188
- headers: { Accept: "*/*", ...authHeaders }
345
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
189
346
  });
190
347
  if (!res.ok) {
191
348
  throw new StarfishHttpError(res.status, await res.text());
192
349
  }
193
350
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
194
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
351
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
195
352
  const data = await res.arrayBuffer();
196
353
  return { data, hash: etag, contentType };
197
354
  }
@@ -200,12 +357,13 @@ var StarfishClient = class {
200
357
  * Binary collections use last-write-wins (no conflict detection).
201
358
  */
202
359
  async pushBlob(path, data, contentType) {
203
- const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
204
- const res = await this.fetch(`${this.baseUrl}${path}`, {
360
+ const sendPath = this.applyNamespace(path);
361
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
362
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
205
363
  method: "POST",
206
364
  headers: {
207
- "Content-Type": contentType,
208
- Accept: "application/json",
365
+ [HEADER_CONTENT_TYPE]: contentType,
366
+ [HEADER_ACCEPT]: "application/json",
209
367
  ...authHeaders
210
368
  },
211
369
  body: data
@@ -218,7 +376,13 @@ var StarfishClient = class {
218
376
  };
219
377
 
220
378
  // src/sync.ts
221
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
379
+ import {
380
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
381
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
382
+ deepMerge,
383
+ docAuthorCanonicalInput,
384
+ getBase64
385
+ } from "@drakkar.software/starfish-protocol";
222
386
 
223
387
  // src/validate.ts
224
388
  var ValidationError = class extends Error {
@@ -330,23 +494,24 @@ var SyncManager = class {
330
494
  try {
331
495
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
332
496
  if (this.aborted) throw new AbortError();
333
- let payload = sealed;
497
+ let author;
334
498
  if (this.signer) {
335
499
  const { devEdPubHex, sign } = await this.signer.getSigner();
336
500
  if (this.aborted) throw new AbortError();
337
- const canonical = stableStringify2(sealed);
501
+ const documentKey = stripPushPrefix(this.pushPath);
502
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
338
503
  const sigBytes = await sign(new TextEncoder().encode(canonical));
339
504
  if (this.aborted) throw new AbortError();
340
- payload = {
341
- ...sealed,
342
- authorPubkey: devEdPubHex,
343
- authorSignature: getBase64().encode(sigBytes)
505
+ author = {
506
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
507
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
344
508
  };
345
509
  }
346
510
  const result = await this.client.push(
347
511
  this.pushPath,
348
- payload,
349
- this.lastHash
512
+ sealed,
513
+ this.lastHash,
514
+ author
350
515
  );
351
516
  if (this.aborted) throw new AbortError();
352
517
  this.lastHash = result.hash;
@@ -388,6 +553,209 @@ var SyncManager = class {
388
553
  }
389
554
  };
390
555
 
556
+ // src/append-log.ts
557
+ import {
558
+ DEFAULT_ALG as DEFAULT_ALG2,
559
+ verifyAppendAuthor
560
+ } from "@drakkar.software/starfish-protocol";
561
+ var PULL_PATH_PREFIX = "/pull/";
562
+ function stripPullPrefix(path) {
563
+ return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
564
+ }
565
+ var AppendAuthorError = class extends Error {
566
+ constructor(ts) {
567
+ super(`append element author verification failed (ts=${ts})`);
568
+ this.ts = ts;
569
+ this.name = "AppendAuthorError";
570
+ }
571
+ };
572
+ function checkpointOf(items) {
573
+ let max = 0;
574
+ for (const it of items) if (it.ts > max) max = it.ts;
575
+ return max;
576
+ }
577
+ function withAuthor(ts, data, src) {
578
+ const out = { ts, data };
579
+ if (src.authorPubkey !== void 0) out.authorPubkey = src.authorPubkey;
580
+ if (src.authorSignature !== void 0) out.authorSignature = src.authorSignature;
581
+ return out;
582
+ }
583
+ var AppendLogCursor = class {
584
+ client;
585
+ pullPath;
586
+ appendField;
587
+ encryptor;
588
+ verifyAuthor;
589
+ onElementError;
590
+ persistEncrypted;
591
+ documentKey;
592
+ logger;
593
+ loggerName;
594
+ items;
595
+ lastCheckpoint;
596
+ /** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
597
+ * it so each runs against the checkpoint the previous one advanced — no two
598
+ * overlapping fetches read the same checkpoint and double-append a window. */
599
+ pullChain = Promise.resolve();
600
+ constructor(options) {
601
+ this.client = options.client;
602
+ this.pullPath = options.pullPath;
603
+ this.appendField = options.appendField ?? "items";
604
+ this.encryptor = options.encryptor;
605
+ this.verifyAuthor = options.verifyAuthor;
606
+ this.onElementError = options.onElementError ?? "throw";
607
+ this.persistEncrypted = options.persistEncrypted ?? false;
608
+ this.documentKey = stripPullPrefix(options.pullPath);
609
+ this.logger = options.logger;
610
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
611
+ const seed = options.initialItems ?? [];
612
+ const seedCheckpoint = checkpointOf(seed);
613
+ if (options.since != null) {
614
+ if (options.since < 0) throw new Error("since must be non-negative");
615
+ if (options.since < seedCheckpoint) {
616
+ throw new Error("since must be >= the max ts of initialItems");
617
+ }
618
+ this.lastCheckpoint = options.since;
619
+ } else {
620
+ this.lastCheckpoint = seedCheckpoint;
621
+ }
622
+ this.items = [...seed];
623
+ }
624
+ /**
625
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
626
+ * append them to the local log, and return ONLY the newly-fetched batch
627
+ * (decrypted when an `encryptor` is set).
628
+ *
629
+ * Atomic under `onElementError: "throw"` (the default): the batch is fully
630
+ * verified and decrypted into a local before any state mutation, so a
631
+ * verify/decrypt failure throws without advancing the checkpoint past elements
632
+ * that could never be re-fetched. Under `"skip"`, a failing element is dropped
633
+ * from the returned batch but the checkpoint still advances past it.
634
+ *
635
+ * Safe to call concurrently: overlapping calls are serialized internally, so
636
+ * each runs against the checkpoint the previous one advanced (no double-fetch
637
+ * of the same window). The next pull after one completes will pick up anything
638
+ * that arrived in between.
639
+ */
640
+ async pull() {
641
+ const run = this.pullChain.then(
642
+ () => this.doPull(),
643
+ () => this.doPull()
644
+ );
645
+ this.pullChain = run.then(
646
+ () => void 0,
647
+ () => void 0
648
+ );
649
+ return run;
650
+ }
651
+ async doPull() {
652
+ this.logger?.pullStart(this.loggerName);
653
+ const start = performance.now();
654
+ try {
655
+ const since = this.lastCheckpoint;
656
+ const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField };
657
+ const raw = await this.client.pull(this.pullPath, opts);
658
+ const batch = [];
659
+ const stored = [];
660
+ let maxTs = since;
661
+ let skipped = 0;
662
+ for (const el of raw) {
663
+ if (since > 0 && el.ts <= since) continue;
664
+ if (el.ts > maxTs) maxTs = el.ts;
665
+ let decrypted = null;
666
+ try {
667
+ this.verifyOne(el);
668
+ const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
669
+ decrypted = withAuthor(el.ts, data, el);
670
+ } catch (err) {
671
+ if (this.onElementError !== "skip") throw err;
672
+ skipped++;
673
+ }
674
+ if (this.persistEncrypted) {
675
+ stored.push(withAuthor(el.ts, el.data, el));
676
+ } else if (decrypted) {
677
+ stored.push(decrypted);
678
+ }
679
+ if (decrypted) batch.push(decrypted);
680
+ }
681
+ this.items.push(...stored);
682
+ this.lastCheckpoint = maxTs;
683
+ this.logger?.pullSuccess(
684
+ this.loggerName,
685
+ Math.round(performance.now() - start),
686
+ skipped > 0 ? { skippedCount: skipped } : void 0
687
+ );
688
+ return batch;
689
+ } catch (err) {
690
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
691
+ throw err;
692
+ }
693
+ }
694
+ /** Verify a single element's author signature over its RAW (pre-decryption)
695
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
696
+ * verification is disabled. */
697
+ verifyOne(el) {
698
+ if (!this.verifyAuthor) return;
699
+ const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
700
+ const { authorPubkey, authorSignature } = el;
701
+ if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
702
+ if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
703
+ throw new AppendAuthorError(el.ts);
704
+ }
705
+ const ok = verifyAppendAuthor(
706
+ this.documentKey,
707
+ el.data,
708
+ authorPubkey,
709
+ authorSignature,
710
+ policy.alg ?? DEFAULT_ALG2
711
+ );
712
+ if (!ok) throw new AppendAuthorError(el.ts);
713
+ }
714
+ /** The full accumulated log (a shallow copy), in `ts` order. Under
715
+ * `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
716
+ * re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
717
+ getItems() {
718
+ return [...this.items];
719
+ }
720
+ /**
721
+ * The full accumulated log, DECRYPTED — for rendering warm-started history in
722
+ * `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
723
+ * `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
724
+ * cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
725
+ * elements are already plaintext/decrypted and are returned as-is.
726
+ */
727
+ async getDecryptedItems() {
728
+ const snapshot = [...this.items];
729
+ if (!this.encryptor || !this.persistEncrypted) return snapshot;
730
+ const out = [];
731
+ for (const el of snapshot) {
732
+ try {
733
+ this.verifyOne(el);
734
+ const data = await this.encryptor.decrypt(el.data);
735
+ out.push(withAuthor(el.ts, data, el));
736
+ } catch (err) {
737
+ if (this.onElementError !== "skip") throw err;
738
+ }
739
+ }
740
+ return out;
741
+ }
742
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
743
+ * when nothing has been pulled or seeded. */
744
+ getCheckpoint() {
745
+ return this.lastCheckpoint;
746
+ }
747
+ /** Restore the checkpoint without seeding items — for persistence layers that
748
+ * store only the checkpoint. Used to resume incrementally across restarts.
749
+ * Rejects a value below the max `ts` already held: rewinding would make the
750
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
751
+ setCheckpoint(ts) {
752
+ if (ts < checkpointOf(this.items)) {
753
+ throw new Error("checkpoint must be >= the max ts already held");
754
+ }
755
+ this.lastCheckpoint = ts;
756
+ }
757
+ };
758
+
391
759
  // src/index.ts
392
760
  import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
393
761
 
@@ -1173,6 +1541,29 @@ function createMobileLifecycle(store, deps, options = {}) {
1173
1541
  netUnsub?.();
1174
1542
  };
1175
1543
  }
1544
+ function createAppendLogMobileLifecycle(store, deps, options = {}) {
1545
+ const { pullOnForeground = true } = options;
1546
+ const appSub = deps.appState.addEventListener("change", (appState) => {
1547
+ if (appState === "active" && pullOnForeground) {
1548
+ const { online, loading } = store.getState();
1549
+ if (online && !loading) {
1550
+ store.getState().pull().catch((err) => {
1551
+ console.error("[Starfish] foreground log pull failed:", err);
1552
+ });
1553
+ }
1554
+ }
1555
+ });
1556
+ let netUnsub = null;
1557
+ if (deps.netInfo) {
1558
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
1559
+ store.getState().setOnline(!!isConnected);
1560
+ });
1561
+ }
1562
+ return () => {
1563
+ appSub.remove();
1564
+ netUnsub?.();
1565
+ };
1566
+ }
1176
1567
 
1177
1568
  // src/multi-store.ts
1178
1569
  function createMultiStoreSync(options) {
@@ -1225,6 +1616,8 @@ function createMultiStoreSync(options) {
1225
1616
  }
1226
1617
  export {
1227
1618
  AbortError,
1619
+ AppendAuthorError,
1620
+ AppendLogCursor,
1228
1621
  ConflictError,
1229
1622
  ENCRYPTED_KEY,
1230
1623
  SnapshotHistory,
@@ -1233,10 +1626,12 @@ export {
1233
1626
  SyncManager,
1234
1627
  ValidationError,
1235
1628
  buildRevocationList,
1629
+ checkpointOf,
1236
1630
  classifyError,
1237
1631
  computeHash,
1238
1632
  configurePlatform,
1239
1633
  consoleSyncLogger,
1634
+ createAppendLogMobileLifecycle,
1240
1635
  createDebouncedPush,
1241
1636
  createDebouncedSync,
1242
1637
  createDedupFetch,
@@ -1260,7 +1655,7 @@ export {
1260
1655
  registerBackgroundSync,
1261
1656
  registerServiceWorker,
1262
1657
  revocationListCanonicalSigningInput,
1263
- stableStringify3 as stableStringify,
1658
+ stableStringify2 as stableStringify,
1264
1659
  startAdaptivePolling,
1265
1660
  startPolling,
1266
1661
  timestampWinner,