@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.
@@ -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,26 +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 } = 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
- return {
319
- Authorization: `Cap ${encodeCapAuth(cap)}`,
320
- "X-Starfish-Sig": sig,
321
- "X-Starfish-Ts": String(ts),
322
- "X-Starfish-Nonce": nonce
323
- };
324
- }
325
- 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 };
326
392
  }
327
393
  async pull(path, checkpointOrOptions) {
328
- let pathAndQuery = path;
394
+ let pathAndQuery = this.applyNamespace(path);
329
395
  let appendField;
330
396
  if (typeof checkpointOrOptions === "number") {
331
397
  if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
@@ -357,7 +423,7 @@ var StarfishClient = class {
357
423
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
358
424
  const res = await this.fetch(url, {
359
425
  method: "GET",
360
- headers: { Accept: "application/json", ...authHeaders }
426
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
361
427
  });
362
428
  if (!res.ok) {
363
429
  throw new StarfishHttpError(res.status, await res.text());
@@ -369,27 +435,62 @@ var StarfishClient = class {
369
435
  }
370
436
  return result;
371
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
+ }
372
467
  /**
373
468
  * Push synced data to the server.
374
469
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
375
470
  * @param data - The full document data to push
376
471
  * @param baseHash - Hash of the document this push is based on (null for first push)
377
472
  *
378
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
379
- * 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.
380
476
  * @throws {ConflictError} if the server detects a hash mismatch (409)
381
477
  */
382
- async push(path, data, baseHash) {
478
+ async push(path, data, baseHash, author) {
383
479
  const body = JSON.stringify({
384
- data,
385
- 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
+ }
386
486
  });
387
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
388
- const res = await this.fetch(`${this.baseUrl}${path}`, {
487
+ const sendPath = this.applyNamespace(path);
488
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
489
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
389
490
  method: "POST",
390
491
  headers: {
391
- "Content-Type": "application/json",
392
- Accept: "application/json",
492
+ [HEADER_CONTENT_TYPE]: "application/json",
493
+ [HEADER_ACCEPT]: "application/json",
393
494
  ...authHeaders
394
495
  },
395
496
  body
@@ -402,21 +503,77 @@ var StarfishClient = class {
402
503
  }
403
504
  return res.json();
404
505
  }
506
+ /**
507
+ * Append an element to an appendOnly (`by_timestamp`) collection.
508
+ *
509
+ * Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
510
+ * authorized append is always accepted. Each element is stored server-side as
511
+ * `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
512
+ *
513
+ * @param path - the push endpoint (e.g. "/push/events")
514
+ * @param data - the element payload. For a `delegated` collection, encrypt it
515
+ * first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
516
+ * server stores it opaquely and never reads it.
517
+ * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
518
+ * non-negative integer strictly greater than the latest stored element's ts
519
+ * (else the server responds 409). Omit to let the server assign one.
520
+ * @throws {StarfishHttpError} on a non-2xx response — e.g. 409
521
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
522
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
523
+ * cap is reached (partition by a path parameter for higher volume).
524
+ */
525
+ async append(path, data, opts = {}) {
526
+ const sendPath = this.applyNamespace(path);
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) : {};
547
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
548
+ method: "POST",
549
+ headers: {
550
+ [HEADER_CONTENT_TYPE]: "application/json",
551
+ [HEADER_ACCEPT]: "application/json",
552
+ ...authHeaders
553
+ },
554
+ body
555
+ });
556
+ if (!res.ok) {
557
+ throw new StarfishHttpError(res.status, await res.text());
558
+ }
559
+ return res.json();
560
+ }
405
561
  /**
406
562
  * Pull binary data from a blob collection.
407
563
  * Returns raw bytes with the content hash from the ETag header.
408
564
  */
409
565
  async pullBlob(path) {
410
- const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
411
- const res = await this.fetch(`${this.baseUrl}${path}`, {
566
+ const sendPath = this.applyNamespace(path);
567
+ const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
568
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
412
569
  method: "GET",
413
- headers: { Accept: "*/*", ...authHeaders }
570
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
414
571
  });
415
572
  if (!res.ok) {
416
573
  throw new StarfishHttpError(res.status, await res.text());
417
574
  }
418
575
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
419
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
576
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
420
577
  const data = await res.arrayBuffer();
421
578
  return { data, hash: etag, contentType };
422
579
  }
@@ -425,12 +582,13 @@ var StarfishClient = class {
425
582
  * Binary collections use last-write-wins (no conflict detection).
426
583
  */
427
584
  async pushBlob(path, data, contentType) {
428
- const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
429
- const res = await this.fetch(`${this.baseUrl}${path}`, {
585
+ const sendPath = this.applyNamespace(path);
586
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
587
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
430
588
  method: "POST",
431
589
  headers: {
432
- "Content-Type": contentType,
433
- Accept: "application/json",
590
+ [HEADER_CONTENT_TYPE]: contentType,
591
+ [HEADER_ACCEPT]: "application/json",
434
592
  ...authHeaders
435
593
  },
436
594
  body: data
@@ -443,7 +601,13 @@ var StarfishClient = class {
443
601
  };
444
602
 
445
603
  // src/sync.ts
446
- 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";
447
611
 
448
612
  // src/validate.ts
449
613
  var ValidationError = class extends Error {
@@ -548,23 +712,24 @@ var SyncManager = class {
548
712
  try {
549
713
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
550
714
  if (this.aborted) throw new AbortError();
551
- let payload = sealed;
715
+ let author;
552
716
  if (this.signer) {
553
717
  const { devEdPubHex, sign } = await this.signer.getSigner();
554
718
  if (this.aborted) throw new AbortError();
555
- const canonical = stableStringify2(sealed);
719
+ const documentKey = stripPushPrefix(this.pushPath);
720
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
556
721
  const sigBytes = await sign(new TextEncoder().encode(canonical));
557
722
  if (this.aborted) throw new AbortError();
558
- payload = {
559
- ...sealed,
560
- authorPubkey: devEdPubHex,
561
- authorSignature: getBase64().encode(sigBytes)
723
+ author = {
724
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
725
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
562
726
  };
563
727
  }
564
728
  const result = await this.client.push(
565
729
  this.pushPath,
566
- payload,
567
- this.lastHash
730
+ sealed,
731
+ this.lastHash,
732
+ author
568
733
  );
569
734
  if (this.aborted) throw new AbortError();
570
735
  this.lastHash = result.hash;
@@ -838,6 +1003,7 @@ function useSyncInit(config) {
838
1003
  }
839
1004
  const client = new StarfishClient({
840
1005
  baseUrl: config.serverUrl,
1006
+ namespace: config.namespace,
841
1007
  capProvider: config.capProvider,
842
1008
  fetch: config.fetch
843
1009
  });
@@ -881,16 +1047,101 @@ function useSyncInit(config) {
881
1047
  ]);
882
1048
  return store;
883
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
+ }
884
1128
  export {
885
1129
  aggregateSyncStatus,
1130
+ createStarfishLog,
886
1131
  createStarfishStore,
1132
+ deriveLogStatus,
887
1133
  deriveSyncStatus,
1134
+ subscribeLogStatus,
888
1135
  subscribeSyncStatus,
889
1136
  useConnectivity,
890
1137
  useCrossTabSync,
891
1138
  useLastSynced,
1139
+ useLogConnectivity,
1140
+ useLogStatus,
892
1141
  useStarfish,
893
1142
  useStarfishData,
1143
+ useStarfishLog,
1144
+ useStarfishLogItems,
894
1145
  useSyncInit,
895
1146
  useSyncStatus
896
1147
  };