@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.
@@ -230,6 +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,
247
+ DEFAULT_ALG,
248
+ signAppendAuthor,
233
249
  signRequest,
234
250
  stableStringify
235
251
  } from "@drakkar.software/starfish-protocol";
@@ -252,6 +268,9 @@ var StarfishHttpError = class extends Error {
252
268
 
253
269
  // src/client.ts
254
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
+ }
255
274
  function encodeCapAuth(cap) {
256
275
  const json = stableStringify(cap);
257
276
  if (typeof btoa === "function") {
@@ -263,6 +282,7 @@ function encodeCapAuth(cap) {
263
282
  }
264
283
  var StarfishClient = class {
265
284
  baseUrl;
285
+ namespace;
266
286
  capProvider;
267
287
  fetch;
268
288
  /**
@@ -272,6 +292,7 @@ var StarfishClient = class {
272
292
  plugins;
273
293
  constructor(options) {
274
294
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
295
+ this.namespace = options.namespace || void 0;
275
296
  this.capProvider = options.capProvider;
276
297
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
277
298
  this.plugins = options.plugins ? [...options.plugins] : [];
@@ -295,6 +316,20 @@ var StarfishClient = class {
295
316
  return "";
296
317
  }
297
318
  }
319
+ /**
320
+ * Rewrite a request path for the configured namespace. A no-op when no
321
+ * namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
322
+ * (the `/v1` protocol-version segment is part of the namespaced route, matching
323
+ * the Python client and the server's namespace mount).
324
+ *
325
+ * Applied to the path used for BOTH the signature and the URL so the canonical
326
+ * path the client signs equals the path the server reconstructs from the URL.
327
+ * Covers SDK-helper-built paths too — that's the point: a namespace-unaware
328
+ * helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
329
+ */
330
+ applyNamespace(path) {
331
+ return this.namespace ? `/v1/${this.namespace}${path}` : path;
332
+ }
298
333
  /**
299
334
  * Build auth headers for a request. When a `capProvider` is set, signs the
300
335
  * request with the device's Ed25519 private key and returns the v3 header
@@ -306,28 +341,57 @@ var StarfishClient = class {
306
341
  * The host bound into the signature is derived from `baseUrl` once per call.
307
342
  */
308
343
  async buildAuthHeaders(method, pathAndQuery, body) {
309
- if (this.capProvider) {
310
- const { cap, devEdPrivHex, pubHex } = await this.capProvider.getCap();
311
- const req = {
312
- method,
313
- pathAndQuery,
314
- body,
315
- host: this.signingHost()
316
- };
317
- const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
318
- const headers = {
319
- Authorization: `Cap ${encodeCapAuth(cap)}`,
320
- "X-Starfish-Sig": sig,
321
- "X-Starfish-Ts": String(ts),
322
- "X-Starfish-Nonce": nonce
323
- };
324
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
325
- return headers;
326
- }
327
- 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 };
328
392
  }
329
393
  async pull(path, checkpointOrOptions) {
330
- let pathAndQuery = path;
394
+ let pathAndQuery = this.applyNamespace(path);
331
395
  let appendField;
332
396
  if (typeof checkpointOrOptions === "number") {
333
397
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
@@ -359,7 +423,7 @@ var StarfishClient = class {
359
423
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
360
424
  const res = await this.fetch(url, {
361
425
  method: "GET",
362
- headers: { Accept: "application/json", ...authHeaders }
426
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
363
427
  });
364
428
  if (!res.ok) {
365
429
  throw new StarfishHttpError(res.status, await res.text());
@@ -371,27 +435,78 @@ var StarfishClient = class {
371
435
  }
372
436
  return result;
373
437
  }
438
+ /**
439
+ * Pull several documents in one round-trip via `/batch/pull`. `collections` is
440
+ * the list of distinct collection names; `opts.params` supplies, per collection,
441
+ * an ARRAY of path-param sets — one per document to read — so the SAME collection
442
+ * can fan in many documents (e.g. many users' `profile`) in a single request.
443
+ * The server auto-fills the `{identity}` param from the authenticated caller for
444
+ * any set that omits it, so a self-doc collection needs no params. Returns a map
445
+ * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
446
+ * in request order. Honors the configured namespace.
447
+ *
448
+ * For the common "many docs of one collection" case prefer {@link batchPullMany}.
449
+ *
450
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
451
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
452
+ */
453
+ async batchPull(collections, opts = {}) {
454
+ const search = new URLSearchParams();
455
+ search.set("collections", collections.join(","));
456
+ if (opts.params && Object.keys(opts.params).length > 0) {
457
+ search.set("params", JSON.stringify(opts.params));
458
+ }
459
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
460
+ const url = `${this.baseUrl}${pathAndQuery}`;
461
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
462
+ const res = await this.fetch(url, {
463
+ method: "GET",
464
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
465
+ });
466
+ if (!res.ok) {
467
+ throw new StarfishHttpError(res.status, await res.text());
468
+ }
469
+ return await res.json();
470
+ }
471
+ /**
472
+ * Convenience over {@link batchPull} for reading MANY documents of ONE
473
+ * collection in a single round-trip: pass the per-document param-sets and get
474
+ * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
475
+ * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
476
+ * issues no request and returns `[]`.
477
+ */
478
+ async batchPullMany(collection, paramsList) {
479
+ if (paramsList.length === 0) return [];
480
+ const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
481
+ return res.collections[collection] ?? [];
482
+ }
374
483
  /**
375
484
  * Push synced data to the server.
376
485
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
377
486
  * @param data - The full document data to push
378
487
  * @param baseHash - Hash of the document this push is based on (null for first push)
379
488
  *
380
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
381
- * and are produced by `SyncManager` when a `signer` is configured.
489
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
490
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
491
+ * top-level body siblings of `data`, where the server verifies it.
382
492
  * @throws {ConflictError} if the server detects a hash mismatch (409)
383
493
  */
384
- async push(path, data, baseHash) {
494
+ async push(path, data, baseHash, author) {
385
495
  const body = JSON.stringify({
386
- data,
387
- baseHash
496
+ [DATA_FIELD]: data,
497
+ [BASE_HASH_FIELD]: baseHash,
498
+ ...author && {
499
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
500
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
501
+ }
388
502
  });
389
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
390
- const res = await this.fetch(`${this.baseUrl}${path}`, {
503
+ const sendPath = this.applyNamespace(path);
504
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
505
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
391
506
  method: "POST",
392
507
  headers: {
393
- "Content-Type": "application/json",
394
- Accept: "application/json",
508
+ [HEADER_CONTENT_TYPE]: "application/json",
509
+ [HEADER_ACCEPT]: "application/json",
395
510
  ...authHeaders
396
511
  },
397
512
  body
@@ -404,21 +519,77 @@ var StarfishClient = class {
404
519
  }
405
520
  return res.json();
406
521
  }
522
+ /**
523
+ * Append an element to an appendOnly (`by_timestamp`) collection.
524
+ *
525
+ * Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
526
+ * authorized append is always accepted. Each element is stored server-side as
527
+ * `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
528
+ *
529
+ * @param path - the push endpoint (e.g. "/push/events")
530
+ * @param data - the element payload. For a `delegated` collection, encrypt it
531
+ * first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
532
+ * server stores it opaquely and never reads it.
533
+ * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
534
+ * non-negative integer strictly greater than the latest stored element's ts
535
+ * (else the server responds 409). Omit to let the server assign one.
536
+ * @throws {StarfishHttpError} on a non-2xx response — e.g. 409
537
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
538
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
539
+ * cap is reached (partition by a path parameter for higher volume).
540
+ */
541
+ async append(path, data, opts = {}) {
542
+ const sendPath = this.applyNamespace(path);
543
+ const bodyObj = { [DATA_FIELD]: data };
544
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
545
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
546
+ if (capCtx) {
547
+ const authorKey = this.appendAuthorKey(capCtx);
548
+ if (authorKey) {
549
+ const documentKey = stripPushPrefix(path);
550
+ const { authorPubkey, authorSignature } = signAppendAuthor(
551
+ documentKey,
552
+ data,
553
+ authorKey.authorPubHex,
554
+ capCtx.devEdPrivHex,
555
+ authorKey.signAlg
556
+ );
557
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
558
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
559
+ }
560
+ }
561
+ const body = JSON.stringify(bodyObj);
562
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
563
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
564
+ method: "POST",
565
+ headers: {
566
+ [HEADER_CONTENT_TYPE]: "application/json",
567
+ [HEADER_ACCEPT]: "application/json",
568
+ ...authHeaders
569
+ },
570
+ body
571
+ });
572
+ if (!res.ok) {
573
+ throw new StarfishHttpError(res.status, await res.text());
574
+ }
575
+ return res.json();
576
+ }
407
577
  /**
408
578
  * Pull binary data from a blob collection.
409
579
  * Returns raw bytes with the content hash from the ETag header.
410
580
  */
411
581
  async pullBlob(path) {
412
- const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
413
- const res = await this.fetch(`${this.baseUrl}${path}`, {
582
+ const sendPath = this.applyNamespace(path);
583
+ const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
584
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
414
585
  method: "GET",
415
- headers: { Accept: "*/*", ...authHeaders }
586
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
416
587
  });
417
588
  if (!res.ok) {
418
589
  throw new StarfishHttpError(res.status, await res.text());
419
590
  }
420
591
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
421
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
592
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
422
593
  const data = await res.arrayBuffer();
423
594
  return { data, hash: etag, contentType };
424
595
  }
@@ -427,12 +598,13 @@ var StarfishClient = class {
427
598
  * Binary collections use last-write-wins (no conflict detection).
428
599
  */
429
600
  async pushBlob(path, data, contentType) {
430
- const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
431
- const res = await this.fetch(`${this.baseUrl}${path}`, {
601
+ const sendPath = this.applyNamespace(path);
602
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
603
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
432
604
  method: "POST",
433
605
  headers: {
434
- "Content-Type": contentType,
435
- Accept: "application/json",
606
+ [HEADER_CONTENT_TYPE]: contentType,
607
+ [HEADER_ACCEPT]: "application/json",
436
608
  ...authHeaders
437
609
  },
438
610
  body: data
@@ -445,7 +617,13 @@ var StarfishClient = class {
445
617
  };
446
618
 
447
619
  // src/sync.ts
448
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
620
+ import {
621
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
622
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
623
+ deepMerge,
624
+ docAuthorCanonicalInput,
625
+ getBase64
626
+ } from "@drakkar.software/starfish-protocol";
449
627
 
450
628
  // src/validate.ts
451
629
  var ValidationError = class extends Error {
@@ -550,23 +728,24 @@ var SyncManager = class {
550
728
  try {
551
729
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
552
730
  if (this.aborted) throw new AbortError();
553
- let payload = sealed;
731
+ let author;
554
732
  if (this.signer) {
555
733
  const { devEdPubHex, sign } = await this.signer.getSigner();
556
734
  if (this.aborted) throw new AbortError();
557
- const canonical = stableStringify2(sealed);
735
+ const documentKey = stripPushPrefix(this.pushPath);
736
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
558
737
  const sigBytes = await sign(new TextEncoder().encode(canonical));
559
738
  if (this.aborted) throw new AbortError();
560
- payload = {
561
- ...sealed,
562
- authorPubkey: devEdPubHex,
563
- authorSignature: getBase64().encode(sigBytes)
739
+ author = {
740
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
741
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
564
742
  };
565
743
  }
566
744
  const result = await this.client.push(
567
745
  this.pushPath,
568
- payload,
569
- this.lastHash
746
+ sealed,
747
+ this.lastHash,
748
+ author
570
749
  );
571
750
  if (this.aborted) throw new AbortError();
572
751
  this.lastHash = result.hash;
@@ -840,6 +1019,7 @@ function useSyncInit(config) {
840
1019
  }
841
1020
  const client = new StarfishClient({
842
1021
  baseUrl: config.serverUrl,
1022
+ namespace: config.namespace,
843
1023
  capProvider: config.capProvider,
844
1024
  fetch: config.fetch
845
1025
  });
@@ -883,16 +1063,101 @@ function useSyncInit(config) {
883
1063
  ]);
884
1064
  return store;
885
1065
  }
1066
+ function createStarfishLog(options) {
1067
+ const { cursor } = options;
1068
+ const storeCreator = (rawSet, get) => {
1069
+ const set = rawSet;
1070
+ return {
1071
+ // Seed from the cursor so a warm-started cursor's items show immediately.
1072
+ items: cursor.getItems(),
1073
+ loading: false,
1074
+ online: true,
1075
+ error: null,
1076
+ checkpoint: cursor.getCheckpoint(),
1077
+ pull: async () => {
1078
+ if (get().loading) return [];
1079
+ set({ loading: true, error: null }, false, "log/pull/start");
1080
+ try {
1081
+ const batch = await cursor.pull();
1082
+ set(
1083
+ { items: cursor.getItems(), checkpoint: cursor.getCheckpoint(), loading: false },
1084
+ false,
1085
+ "log/pull/success"
1086
+ );
1087
+ return batch;
1088
+ } catch (err) {
1089
+ set({ loading: false, error: err instanceof Error ? err.message : String(err) }, false, "log/pull/error");
1090
+ return [];
1091
+ }
1092
+ },
1093
+ setOnline: (online) => {
1094
+ set({ online }, false, "log/setOnline");
1095
+ }
1096
+ };
1097
+ };
1098
+ const withSelector = subscribeWithSelector(storeCreator);
1099
+ return createStore()(
1100
+ options.devtools ? options.devtools(withSelector) : withSelector
1101
+ );
1102
+ }
1103
+ function deriveLogStatus(state) {
1104
+ if (!state.online) return "offline";
1105
+ if (state.error) return "error";
1106
+ if (state.loading) return "loading";
1107
+ return "idle";
1108
+ }
1109
+ function useStarfishLog(store) {
1110
+ return useStore(store);
1111
+ }
1112
+ function useStarfishLogItems(store, selector) {
1113
+ return useStore(
1114
+ store,
1115
+ (state) => selector ? selector(state.items) : state.items
1116
+ );
1117
+ }
1118
+ function useLogStatus(store) {
1119
+ return useStore(store, deriveLogStatus);
1120
+ }
1121
+ function subscribeLogStatus(store, callback) {
1122
+ let prev = deriveLogStatus(store.getState());
1123
+ callback(prev);
1124
+ return store.subscribe((state) => {
1125
+ const next = deriveLogStatus(state);
1126
+ if (next !== prev) {
1127
+ prev = next;
1128
+ callback(next);
1129
+ }
1130
+ });
1131
+ }
1132
+ function useLogConnectivity(store) {
1133
+ useEffect(() => {
1134
+ const handleOnline = () => store.getState().setOnline(true);
1135
+ const handleOffline = () => store.getState().setOnline(false);
1136
+ window.addEventListener("online", handleOnline);
1137
+ window.addEventListener("offline", handleOffline);
1138
+ return () => {
1139
+ window.removeEventListener("online", handleOnline);
1140
+ window.removeEventListener("offline", handleOffline);
1141
+ };
1142
+ }, [store]);
1143
+ }
886
1144
  export {
887
1145
  aggregateSyncStatus,
1146
+ createStarfishLog,
888
1147
  createStarfishStore,
1148
+ deriveLogStatus,
889
1149
  deriveSyncStatus,
1150
+ subscribeLogStatus,
890
1151
  subscribeSyncStatus,
891
1152
  useConnectivity,
892
1153
  useCrossTabSync,
893
1154
  useLastSynced,
1155
+ useLogConnectivity,
1156
+ useLogStatus,
894
1157
  useStarfish,
895
1158
  useStarfishData,
1159
+ useStarfishLog,
1160
+ useStarfishLogItems,
896
1161
  useSyncInit,
897
1162
  useSyncStatus
898
1163
  };