@drakkar.software/starfish-client 3.0.0-alpha.7 → 3.0.0-alpha.9

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,11 +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,
8
22
  DEFAULT_ALG,
23
+ signAppendAuthor,
9
24
  signRequest,
10
25
  stableStringify
11
26
  } from "@drakkar.software/starfish-protocol";
@@ -28,6 +43,9 @@ var StarfishHttpError = class extends Error {
28
43
 
29
44
  // src/client.ts
30
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
+ }
31
49
  function encodeCapAuth(cap) {
32
50
  const json = stableStringify(cap);
33
51
  if (typeof btoa === "function") {
@@ -98,29 +116,54 @@ var StarfishClient = class {
98
116
  * The host bound into the signature is derived from `baseUrl` once per call.
99
117
  */
100
118
  async buildAuthHeaders(method, pathAndQuery, body) {
101
- if (this.capProvider) {
102
- const { cap, devEdPrivHex, pubHex, presenterAlg } = await this.capProvider.getCap();
103
- const req = {
104
- method,
105
- pathAndQuery,
106
- body,
107
- host: this.signingHost()
108
- };
109
- const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
110
- const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
111
- alg: signAlg
112
- });
113
- const headers = {
114
- Authorization: `Cap ${encodeCapAuth(cap)}`,
115
- "X-Starfish-Sig": sig,
116
- "X-Starfish-Ts": String(ts),
117
- "X-Starfish-Nonce": nonce,
118
- "X-Starfish-Alg": alg
119
- };
120
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
121
- return headers;
122
- }
123
- 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 };
124
167
  }
125
168
  async pull(path, checkpointOrOptions) {
126
169
  let pathAndQuery = this.applyNamespace(path);
@@ -155,7 +198,7 @@ var StarfishClient = class {
155
198
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
156
199
  const res = await this.fetch(url, {
157
200
  method: "GET",
158
- headers: { Accept: "application/json", ...authHeaders }
201
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
159
202
  });
160
203
  if (!res.ok) {
161
204
  throw new StarfishHttpError(res.status, await res.text());
@@ -167,28 +210,62 @@ var StarfishClient = class {
167
210
  }
168
211
  return result;
169
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
+ }
170
242
  /**
171
243
  * Push synced data to the server.
172
244
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
173
245
  * @param data - The full document data to push
174
246
  * @param baseHash - Hash of the document this push is based on (null for first push)
175
247
  *
176
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
177
- * 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.
178
251
  * @throws {ConflictError} if the server detects a hash mismatch (409)
179
252
  */
180
- async push(path, data, baseHash) {
253
+ async push(path, data, baseHash, author) {
181
254
  const body = JSON.stringify({
182
- data,
183
- 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
+ }
184
261
  });
185
262
  const sendPath = this.applyNamespace(path);
186
263
  const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
187
264
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
188
265
  method: "POST",
189
266
  headers: {
190
- "Content-Type": "application/json",
191
- Accept: "application/json",
267
+ [HEADER_CONTENT_TYPE]: "application/json",
268
+ [HEADER_ACCEPT]: "application/json",
192
269
  ...authHeaders
193
270
  },
194
271
  body
@@ -221,16 +298,32 @@ var StarfishClient = class {
221
298
  * cap is reached (partition by a path parameter for higher volume).
222
299
  */
223
300
  async append(path, data, opts = {}) {
224
- const bodyObj = { data };
225
- if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
226
- const body = JSON.stringify(bodyObj);
227
301
  const sendPath = this.applyNamespace(path);
228
- const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
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) : {};
229
322
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
230
323
  method: "POST",
231
324
  headers: {
232
- "Content-Type": "application/json",
233
- Accept: "application/json",
325
+ [HEADER_CONTENT_TYPE]: "application/json",
326
+ [HEADER_ACCEPT]: "application/json",
234
327
  ...authHeaders
235
328
  },
236
329
  body
@@ -249,13 +342,13 @@ var StarfishClient = class {
249
342
  const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
250
343
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
251
344
  method: "GET",
252
- headers: { Accept: "*/*", ...authHeaders }
345
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
253
346
  });
254
347
  if (!res.ok) {
255
348
  throw new StarfishHttpError(res.status, await res.text());
256
349
  }
257
350
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
258
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
351
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
259
352
  const data = await res.arrayBuffer();
260
353
  return { data, hash: etag, contentType };
261
354
  }
@@ -269,8 +362,8 @@ var StarfishClient = class {
269
362
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
270
363
  method: "POST",
271
364
  headers: {
272
- "Content-Type": contentType,
273
- Accept: "application/json",
365
+ [HEADER_CONTENT_TYPE]: contentType,
366
+ [HEADER_ACCEPT]: "application/json",
274
367
  ...authHeaders
275
368
  },
276
369
  body: data
@@ -283,7 +376,13 @@ var StarfishClient = class {
283
376
  };
284
377
 
285
378
  // src/sync.ts
286
- 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";
287
386
 
288
387
  // src/validate.ts
289
388
  var ValidationError = class extends Error {
@@ -395,23 +494,24 @@ var SyncManager = class {
395
494
  try {
396
495
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
397
496
  if (this.aborted) throw new AbortError();
398
- let payload = sealed;
497
+ let author;
399
498
  if (this.signer) {
400
499
  const { devEdPubHex, sign } = await this.signer.getSigner();
401
500
  if (this.aborted) throw new AbortError();
402
- const canonical = stableStringify2(sealed);
501
+ const documentKey = stripPushPrefix(this.pushPath);
502
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
403
503
  const sigBytes = await sign(new TextEncoder().encode(canonical));
404
504
  if (this.aborted) throw new AbortError();
405
- payload = {
406
- ...sealed,
407
- authorPubkey: devEdPubHex,
408
- authorSignature: getBase64().encode(sigBytes)
505
+ author = {
506
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
507
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
409
508
  };
410
509
  }
411
510
  const result = await this.client.push(
412
511
  this.pushPath,
413
- payload,
414
- this.lastHash
512
+ sealed,
513
+ this.lastHash,
514
+ author
415
515
  );
416
516
  if (this.aborted) throw new AbortError();
417
517
  this.lastHash = result.hash;
@@ -453,6 +553,140 @@ var SyncManager = class {
453
553
  }
454
554
  };
455
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
+ var AppendLogCursor = class {
578
+ client;
579
+ pullPath;
580
+ appendField;
581
+ encryptor;
582
+ verifyAuthor;
583
+ documentKey;
584
+ logger;
585
+ loggerName;
586
+ items;
587
+ lastCheckpoint;
588
+ constructor(options) {
589
+ this.client = options.client;
590
+ this.pullPath = options.pullPath;
591
+ this.appendField = options.appendField ?? "items";
592
+ this.encryptor = options.encryptor;
593
+ this.verifyAuthor = options.verifyAuthor;
594
+ this.documentKey = stripPullPrefix(options.pullPath);
595
+ this.logger = options.logger;
596
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
597
+ const seed = options.initialItems ?? [];
598
+ const seedCheckpoint = checkpointOf(seed);
599
+ if (options.since != null) {
600
+ if (options.since < 0) throw new Error("since must be non-negative");
601
+ if (options.since < seedCheckpoint) {
602
+ throw new Error("since must be >= the max ts of initialItems");
603
+ }
604
+ this.lastCheckpoint = options.since;
605
+ } else {
606
+ this.lastCheckpoint = seedCheckpoint;
607
+ }
608
+ this.items = [...seed];
609
+ }
610
+ /**
611
+ * Fetch elements newer than the current checkpoint, verify + decrypt them,
612
+ * append them to the local log, and return ONLY the newly-fetched batch.
613
+ *
614
+ * Atomic: the batch is fully verified and decrypted into a local before any
615
+ * state mutation, so a verify/decrypt failure throws without advancing the
616
+ * checkpoint past elements that could never be re-fetched.
617
+ *
618
+ * Not safe to call concurrently: like `SyncManager.pull`, overlapping calls
619
+ * read the same checkpoint and would fetch — and append — the same window twice.
620
+ */
621
+ async pull() {
622
+ this.logger?.pullStart(this.loggerName);
623
+ const start = performance.now();
624
+ try {
625
+ const since = this.lastCheckpoint;
626
+ const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField };
627
+ const raw = await this.client.pull(this.pullPath, opts);
628
+ const batch = [];
629
+ let maxTs = since;
630
+ for (const el of raw) {
631
+ if (since > 0 && el.ts <= since) continue;
632
+ this.verifyOne(el);
633
+ const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
634
+ const out = { ts: el.ts, data };
635
+ if (el.authorPubkey !== void 0) out.authorPubkey = el.authorPubkey;
636
+ if (el.authorSignature !== void 0) out.authorSignature = el.authorSignature;
637
+ batch.push(out);
638
+ if (el.ts > maxTs) maxTs = el.ts;
639
+ }
640
+ this.items.push(...batch);
641
+ this.lastCheckpoint = maxTs;
642
+ this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
643
+ return batch;
644
+ } catch (err) {
645
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
646
+ throw err;
647
+ }
648
+ }
649
+ /** Verify a single element's author signature over its RAW (pre-decryption)
650
+ * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
651
+ * verification is disabled. */
652
+ verifyOne(el) {
653
+ if (!this.verifyAuthor) return;
654
+ const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
655
+ const { authorPubkey, authorSignature } = el;
656
+ if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
657
+ if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
658
+ throw new AppendAuthorError(el.ts);
659
+ }
660
+ const ok = verifyAppendAuthor(
661
+ this.documentKey,
662
+ el.data,
663
+ authorPubkey,
664
+ authorSignature,
665
+ policy.alg ?? DEFAULT_ALG2
666
+ );
667
+ if (!ok) throw new AppendAuthorError(el.ts);
668
+ }
669
+ /** The full accumulated log (a shallow copy), in `ts` order. */
670
+ getItems() {
671
+ return [...this.items];
672
+ }
673
+ /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
674
+ * when nothing has been pulled or seeded. */
675
+ getCheckpoint() {
676
+ return this.lastCheckpoint;
677
+ }
678
+ /** Restore the checkpoint without seeding items — for persistence layers that
679
+ * store only the checkpoint. Used to resume incrementally across restarts.
680
+ * Rejects a value below the max `ts` already held: rewinding would make the
681
+ * next pull re-deliver, and duplicate, elements the cursor already has. */
682
+ setCheckpoint(ts) {
683
+ if (ts < checkpointOf(this.items)) {
684
+ throw new Error("checkpoint must be >= the max ts already held");
685
+ }
686
+ this.lastCheckpoint = ts;
687
+ }
688
+ };
689
+
456
690
  // src/index.ts
457
691
  import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
458
692
 
@@ -1238,6 +1472,29 @@ function createMobileLifecycle(store, deps, options = {}) {
1238
1472
  netUnsub?.();
1239
1473
  };
1240
1474
  }
1475
+ function createAppendLogMobileLifecycle(store, deps, options = {}) {
1476
+ const { pullOnForeground = true } = options;
1477
+ const appSub = deps.appState.addEventListener("change", (appState) => {
1478
+ if (appState === "active" && pullOnForeground) {
1479
+ const { online, loading } = store.getState();
1480
+ if (online && !loading) {
1481
+ store.getState().pull().catch((err) => {
1482
+ console.error("[Starfish] foreground log pull failed:", err);
1483
+ });
1484
+ }
1485
+ }
1486
+ });
1487
+ let netUnsub = null;
1488
+ if (deps.netInfo) {
1489
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
1490
+ store.getState().setOnline(!!isConnected);
1491
+ });
1492
+ }
1493
+ return () => {
1494
+ appSub.remove();
1495
+ netUnsub?.();
1496
+ };
1497
+ }
1241
1498
 
1242
1499
  // src/multi-store.ts
1243
1500
  function createMultiStoreSync(options) {
@@ -1290,6 +1547,8 @@ function createMultiStoreSync(options) {
1290
1547
  }
1291
1548
  export {
1292
1549
  AbortError,
1550
+ AppendAuthorError,
1551
+ AppendLogCursor,
1293
1552
  ConflictError,
1294
1553
  ENCRYPTED_KEY,
1295
1554
  SnapshotHistory,
@@ -1298,10 +1557,12 @@ export {
1298
1557
  SyncManager,
1299
1558
  ValidationError,
1300
1559
  buildRevocationList,
1560
+ checkpointOf,
1301
1561
  classifyError,
1302
1562
  computeHash,
1303
1563
  configurePlatform,
1304
1564
  consoleSyncLogger,
1565
+ createAppendLogMobileLifecycle,
1305
1566
  createDebouncedPush,
1306
1567
  createDebouncedSync,
1307
1568
  createDedupFetch,
@@ -1325,7 +1586,7 @@ export {
1325
1586
  registerBackgroundSync,
1326
1587
  registerServiceWorker,
1327
1588
  revocationListCanonicalSigningInput,
1328
- stableStringify3 as stableStringify,
1589
+ stableStringify2 as stableStringify,
1329
1590
  startAdaptivePolling,
1330
1591
  startPolling,
1331
1592
  timestampWinner,