@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.
@@ -230,7 +230,22 @@ import { useEffect, useRef, useState, useCallback } from "react";
230
230
 
231
231
  // src/client.ts
232
232
  import {
233
+ AUTHOR_PUBKEY_FIELD,
234
+ AUTHOR_SIGNATURE_FIELD,
235
+ DATA_FIELD,
236
+ TS_FIELD,
237
+ BASE_HASH_FIELD,
238
+ PUSH_PATH_PREFIX,
239
+ HEADER_AUTHORIZATION,
240
+ HEADER_SIG,
241
+ HEADER_TS,
242
+ HEADER_NONCE,
243
+ HEADER_ALG,
244
+ HEADER_PUB,
245
+ HEADER_CONTENT_TYPE,
246
+ HEADER_ACCEPT,
233
247
  DEFAULT_ALG,
248
+ signAppendAuthor,
234
249
  signRequest,
235
250
  stableStringify
236
251
  } from "@drakkar.software/starfish-protocol";
@@ -253,6 +268,9 @@ var StarfishHttpError = class extends Error {
253
268
 
254
269
  // src/client.ts
255
270
  var APPEND_DEFAULT_FIELD = "items";
271
+ function stripPushPrefix(path) {
272
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
273
+ }
256
274
  function encodeCapAuth(cap) {
257
275
  const json = stableStringify(cap);
258
276
  if (typeof btoa === "function") {
@@ -323,29 +341,54 @@ var StarfishClient = class {
323
341
  * The host bound into the signature is derived from `baseUrl` once per call.
324
342
  */
325
343
  async buildAuthHeaders(method, pathAndQuery, body) {
326
- if (this.capProvider) {
327
- const { cap, devEdPrivHex, pubHex, presenterAlg } = await this.capProvider.getCap();
328
- const req = {
329
- method,
330
- pathAndQuery,
331
- body,
332
- host: this.signingHost()
333
- };
334
- const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
335
- const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
336
- alg: signAlg
337
- });
338
- const headers = {
339
- Authorization: `Cap ${encodeCapAuth(cap)}`,
340
- "X-Starfish-Sig": sig,
341
- "X-Starfish-Ts": String(ts),
342
- "X-Starfish-Nonce": nonce,
343
- "X-Starfish-Alg": alg
344
- };
345
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
346
- return headers;
347
- }
348
- return {};
344
+ if (!this.capProvider) return {};
345
+ const capCtx = await this.capProvider.getCap();
346
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
347
+ }
348
+ /**
349
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
350
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
351
+ * reuse it for BOTH the author signature (over the element data) and the
352
+ * request signature (over the body), without redeeming the cap twice a
353
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
354
+ * presenter` bind the server checks.
355
+ */
356
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
357
+ const { cap, devEdPrivHex, pubHex, presenterAlg } = capCtx;
358
+ const req = {
359
+ method,
360
+ pathAndQuery,
361
+ body,
362
+ host: this.signingHost()
363
+ };
364
+ const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
365
+ const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
366
+ alg: signAlg
367
+ });
368
+ const headers = {
369
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
370
+ [HEADER_SIG]: sig,
371
+ [HEADER_TS]: String(ts),
372
+ [HEADER_NONCE]: nonce,
373
+ [HEADER_ALG]: alg
374
+ };
375
+ if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
376
+ return headers;
377
+ }
378
+ /**
379
+ * Resolve the author public key to attach to a signed append: the redeemer's
380
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
381
+ * device/member cap. This is the SAME key that signs the request, so a server
382
+ * enforcing author proof can bind the stored element to its writer. Returns
383
+ * undefined only for a (malformed) cap with neither — the append then goes
384
+ * unsigned and a server requiring signatures rejects it.
385
+ */
386
+ appendAuthorKey(capCtx) {
387
+ const { cap, pubHex, presenterAlg } = capCtx;
388
+ const authorPubHex = pubHex ?? cap.sub;
389
+ if (authorPubHex === void 0) return null;
390
+ const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
391
+ return { authorPubHex, signAlg };
349
392
  }
350
393
  async pull(path, checkpointOrOptions) {
351
394
  let pathAndQuery = this.applyNamespace(path);
@@ -380,7 +423,7 @@ var StarfishClient = class {
380
423
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
381
424
  const res = await this.fetch(url, {
382
425
  method: "GET",
383
- headers: { Accept: "application/json", ...authHeaders }
426
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
384
427
  });
385
428
  if (!res.ok) {
386
429
  throw new StarfishHttpError(res.status, await res.text());
@@ -392,28 +435,62 @@ var StarfishClient = class {
392
435
  }
393
436
  return result;
394
437
  }
438
+ /**
439
+ * Pull several collections in one round-trip via `/batch/pull`. `collections`
440
+ * is the list of collection names; `opts.params` supplies path params per
441
+ * collection (serialized to a URL-encoded JSON `params` query). The server
442
+ * auto-fills the `{identity}` param from the authenticated caller, so per-user
443
+ * collections need no params. Returns a map of collection name → its pulled
444
+ * document or a per-collection `{ error }`. Honors the configured namespace.
445
+ *
446
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
447
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
448
+ */
449
+ async batchPull(collections, opts = {}) {
450
+ const search = new URLSearchParams();
451
+ search.set("collections", collections.join(","));
452
+ if (opts.params && Object.keys(opts.params).length > 0) {
453
+ search.set("params", JSON.stringify(opts.params));
454
+ }
455
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
456
+ const url = `${this.baseUrl}${pathAndQuery}`;
457
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
458
+ const res = await this.fetch(url, {
459
+ method: "GET",
460
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
461
+ });
462
+ if (!res.ok) {
463
+ throw new StarfishHttpError(res.status, await res.text());
464
+ }
465
+ return await res.json();
466
+ }
395
467
  /**
396
468
  * Push synced data to the server.
397
469
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
398
470
  * @param data - The full document data to push
399
471
  * @param baseHash - Hash of the document this push is based on (null for first push)
400
472
  *
401
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
402
- * and are produced by `SyncManager` when a `signer` is configured.
473
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
474
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
475
+ * top-level body siblings of `data`, where the server verifies it.
403
476
  * @throws {ConflictError} if the server detects a hash mismatch (409)
404
477
  */
405
- async push(path, data, baseHash) {
478
+ async push(path, data, baseHash, author) {
406
479
  const body = JSON.stringify({
407
- data,
408
- baseHash
480
+ [DATA_FIELD]: data,
481
+ [BASE_HASH_FIELD]: baseHash,
482
+ ...author && {
483
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
484
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
485
+ }
409
486
  });
410
487
  const sendPath = this.applyNamespace(path);
411
488
  const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
412
489
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
413
490
  method: "POST",
414
491
  headers: {
415
- "Content-Type": "application/json",
416
- Accept: "application/json",
492
+ [HEADER_CONTENT_TYPE]: "application/json",
493
+ [HEADER_ACCEPT]: "application/json",
417
494
  ...authHeaders
418
495
  },
419
496
  body
@@ -446,16 +523,32 @@ var StarfishClient = class {
446
523
  * cap is reached (partition by a path parameter for higher volume).
447
524
  */
448
525
  async append(path, data, opts = {}) {
449
- const bodyObj = { data };
450
- if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
451
- const body = JSON.stringify(bodyObj);
452
526
  const sendPath = this.applyNamespace(path);
453
- const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
527
+ const bodyObj = { [DATA_FIELD]: data };
528
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
529
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
530
+ if (capCtx) {
531
+ const authorKey = this.appendAuthorKey(capCtx);
532
+ if (authorKey) {
533
+ const documentKey = stripPushPrefix(path);
534
+ const { authorPubkey, authorSignature } = signAppendAuthor(
535
+ documentKey,
536
+ data,
537
+ authorKey.authorPubHex,
538
+ capCtx.devEdPrivHex,
539
+ authorKey.signAlg
540
+ );
541
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
542
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
543
+ }
544
+ }
545
+ const body = JSON.stringify(bodyObj);
546
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
454
547
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
455
548
  method: "POST",
456
549
  headers: {
457
- "Content-Type": "application/json",
458
- Accept: "application/json",
550
+ [HEADER_CONTENT_TYPE]: "application/json",
551
+ [HEADER_ACCEPT]: "application/json",
459
552
  ...authHeaders
460
553
  },
461
554
  body
@@ -474,13 +567,13 @@ var StarfishClient = class {
474
567
  const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
475
568
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
476
569
  method: "GET",
477
- headers: { Accept: "*/*", ...authHeaders }
570
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
478
571
  });
479
572
  if (!res.ok) {
480
573
  throw new StarfishHttpError(res.status, await res.text());
481
574
  }
482
575
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
483
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
576
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
484
577
  const data = await res.arrayBuffer();
485
578
  return { data, hash: etag, contentType };
486
579
  }
@@ -494,8 +587,8 @@ var StarfishClient = class {
494
587
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
495
588
  method: "POST",
496
589
  headers: {
497
- "Content-Type": contentType,
498
- Accept: "application/json",
590
+ [HEADER_CONTENT_TYPE]: contentType,
591
+ [HEADER_ACCEPT]: "application/json",
499
592
  ...authHeaders
500
593
  },
501
594
  body: data
@@ -508,7 +601,13 @@ var StarfishClient = class {
508
601
  };
509
602
 
510
603
  // src/sync.ts
511
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
604
+ import {
605
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
606
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
607
+ deepMerge,
608
+ docAuthorCanonicalInput,
609
+ getBase64
610
+ } from "@drakkar.software/starfish-protocol";
512
611
 
513
612
  // src/validate.ts
514
613
  var ValidationError = class extends Error {
@@ -613,23 +712,24 @@ var SyncManager = class {
613
712
  try {
614
713
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
615
714
  if (this.aborted) throw new AbortError();
616
- let payload = sealed;
715
+ let author;
617
716
  if (this.signer) {
618
717
  const { devEdPubHex, sign } = await this.signer.getSigner();
619
718
  if (this.aborted) throw new AbortError();
620
- const canonical = stableStringify2(sealed);
719
+ const documentKey = stripPushPrefix(this.pushPath);
720
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
621
721
  const sigBytes = await sign(new TextEncoder().encode(canonical));
622
722
  if (this.aborted) throw new AbortError();
623
- payload = {
624
- ...sealed,
625
- authorPubkey: devEdPubHex,
626
- authorSignature: getBase64().encode(sigBytes)
723
+ author = {
724
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
725
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
627
726
  };
628
727
  }
629
728
  const result = await this.client.push(
630
729
  this.pushPath,
631
- payload,
632
- this.lastHash
730
+ sealed,
731
+ this.lastHash,
732
+ author
633
733
  );
634
734
  if (this.aborted) throw new AbortError();
635
735
  this.lastHash = result.hash;
@@ -947,16 +1047,101 @@ function useSyncInit(config) {
947
1047
  ]);
948
1048
  return store;
949
1049
  }
1050
+ function createStarfishLog(options) {
1051
+ const { cursor } = options;
1052
+ const storeCreator = (rawSet, get) => {
1053
+ const set = rawSet;
1054
+ return {
1055
+ // Seed from the cursor so a warm-started cursor's items show immediately.
1056
+ items: cursor.getItems(),
1057
+ loading: false,
1058
+ online: true,
1059
+ error: null,
1060
+ checkpoint: cursor.getCheckpoint(),
1061
+ pull: async () => {
1062
+ if (get().loading) return [];
1063
+ set({ loading: true, error: null }, false, "log/pull/start");
1064
+ try {
1065
+ const batch = await cursor.pull();
1066
+ set(
1067
+ { items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
1068
+ false,
1069
+ "log/pull/success"
1070
+ );
1071
+ return batch;
1072
+ } catch (err) {
1073
+ set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
1074
+ return [];
1075
+ }
1076
+ },
1077
+ setOnline: (online) => {
1078
+ set({ online }, false, "log/setOnline");
1079
+ }
1080
+ };
1081
+ };
1082
+ const withSelector = subscribeWithSelector(storeCreator);
1083
+ return createStore()(
1084
+ options.devtools ? options.devtools(withSelector) : withSelector
1085
+ );
1086
+ }
1087
+ function deriveLogStatus(state) {
1088
+ if (!state.online) return "offline";
1089
+ if (state.error) return "error";
1090
+ if (state.loading) return "loading";
1091
+ return "idle";
1092
+ }
1093
+ function useStarfishLog(store) {
1094
+ return useStore(store);
1095
+ }
1096
+ function useStarfishLogItems(store, selector) {
1097
+ return useStore(
1098
+ store,
1099
+ (state) => selector ? selector(state.items) : state.items
1100
+ );
1101
+ }
1102
+ function useLogStatus(store) {
1103
+ return useStore(store, deriveLogStatus);
1104
+ }
1105
+ function subscribeLogStatus(store, callback) {
1106
+ let prev = deriveLogStatus(store.getState());
1107
+ callback(prev);
1108
+ return store.subscribe((state) => {
1109
+ const next = deriveLogStatus(state);
1110
+ if (next !== prev) {
1111
+ prev = next;
1112
+ callback(next);
1113
+ }
1114
+ });
1115
+ }
1116
+ function useLogConnectivity(store) {
1117
+ useEffect(() => {
1118
+ const handleOnline = () => store.getState().setOnline(true);
1119
+ const handleOffline = () => store.getState().setOnline(false);
1120
+ window.addEventListener("online", handleOnline);
1121
+ window.addEventListener("offline", handleOffline);
1122
+ return () => {
1123
+ window.removeEventListener("online", handleOnline);
1124
+ window.removeEventListener("offline", handleOffline);
1125
+ };
1126
+ }, [store]);
1127
+ }
950
1128
  export {
951
1129
  aggregateSyncStatus,
1130
+ createStarfishLog,
952
1131
  createStarfishStore,
1132
+ deriveLogStatus,
953
1133
  deriveSyncStatus,
1134
+ subscribeLogStatus,
954
1135
  subscribeSyncStatus,
955
1136
  useConnectivity,
956
1137
  useCrossTabSync,
957
1138
  useLastSynced,
1139
+ useLogConnectivity,
1140
+ useLogStatus,
958
1141
  useStarfish,
959
1142
  useStarfishData,
1143
+ useStarfishLog,
1144
+ useStarfishLogItems,
960
1145
  useSyncInit,
961
1146
  useSyncStatus
962
1147
  };