@drakkar.software/starfish-client 3.0.0-alpha.1 → 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,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,62 @@ var StarfishClient = class {
371
435
  }
372
436
  return result;
373
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
+ }
374
467
  /**
375
468
  * Push synced data to the server.
376
469
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
377
470
  * @param data - The full document data to push
378
471
  * @param baseHash - Hash of the document this push is based on (null for first push)
379
472
  *
380
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
381
- * 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.
382
476
  * @throws {ConflictError} if the server detects a hash mismatch (409)
383
477
  */
384
- async push(path, data, baseHash) {
478
+ async push(path, data, baseHash, author) {
385
479
  const body = JSON.stringify({
386
- data,
387
- 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
+ }
388
486
  });
389
- const authHeaders = await this.buildAuthHeaders("POST", path, body);
390
- 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}`, {
391
490
  method: "POST",
392
491
  headers: {
393
- "Content-Type": "application/json",
394
- Accept: "application/json",
492
+ [HEADER_CONTENT_TYPE]: "application/json",
493
+ [HEADER_ACCEPT]: "application/json",
395
494
  ...authHeaders
396
495
  },
397
496
  body
@@ -404,21 +503,77 @@ var StarfishClient = class {
404
503
  }
405
504
  return res.json();
406
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
+ }
407
561
  /**
408
562
  * Pull binary data from a blob collection.
409
563
  * Returns raw bytes with the content hash from the ETag header.
410
564
  */
411
565
  async pullBlob(path) {
412
- const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
413
- 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}`, {
414
569
  method: "GET",
415
- headers: { Accept: "*/*", ...authHeaders }
570
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
416
571
  });
417
572
  if (!res.ok) {
418
573
  throw new StarfishHttpError(res.status, await res.text());
419
574
  }
420
575
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
421
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
576
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
422
577
  const data = await res.arrayBuffer();
423
578
  return { data, hash: etag, contentType };
424
579
  }
@@ -427,12 +582,13 @@ var StarfishClient = class {
427
582
  * Binary collections use last-write-wins (no conflict detection).
428
583
  */
429
584
  async pushBlob(path, data, contentType) {
430
- const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
431
- 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}`, {
432
588
  method: "POST",
433
589
  headers: {
434
- "Content-Type": contentType,
435
- Accept: "application/json",
590
+ [HEADER_CONTENT_TYPE]: contentType,
591
+ [HEADER_ACCEPT]: "application/json",
436
592
  ...authHeaders
437
593
  },
438
594
  body: data
@@ -445,7 +601,13 @@ var StarfishClient = class {
445
601
  };
446
602
 
447
603
  // src/sync.ts
448
- 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";
449
611
 
450
612
  // src/validate.ts
451
613
  var ValidationError = class extends Error {
@@ -550,23 +712,24 @@ var SyncManager = class {
550
712
  try {
551
713
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
552
714
  if (this.aborted) throw new AbortError();
553
- let payload = sealed;
715
+ let author;
554
716
  if (this.signer) {
555
717
  const { devEdPubHex, sign } = await this.signer.getSigner();
556
718
  if (this.aborted) throw new AbortError();
557
- const canonical = stableStringify2(sealed);
719
+ const documentKey = stripPushPrefix(this.pushPath);
720
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
558
721
  const sigBytes = await sign(new TextEncoder().encode(canonical));
559
722
  if (this.aborted) throw new AbortError();
560
- payload = {
561
- ...sealed,
562
- authorPubkey: devEdPubHex,
563
- authorSignature: getBase64().encode(sigBytes)
723
+ author = {
724
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
725
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
564
726
  };
565
727
  }
566
728
  const result = await this.client.push(
567
729
  this.pushPath,
568
- payload,
569
- this.lastHash
730
+ sealed,
731
+ this.lastHash,
732
+ author
570
733
  );
571
734
  if (this.aborted) throw new AbortError();
572
735
  this.lastHash = result.hash;
@@ -840,6 +1003,7 @@ function useSyncInit(config) {
840
1003
  }
841
1004
  const client = new StarfishClient({
842
1005
  baseUrl: config.serverUrl,
1006
+ namespace: config.namespace,
843
1007
  capProvider: config.capProvider,
844
1008
  fetch: config.fetch
845
1009
  });
@@ -883,16 +1047,101 @@ function useSyncInit(config) {
883
1047
  ]);
884
1048
  return store;
885
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
+ }
886
1128
  export {
887
1129
  aggregateSyncStatus,
1130
+ createStarfishLog,
888
1131
  createStarfishStore,
1132
+ deriveLogStatus,
889
1133
  deriveSyncStatus,
1134
+ subscribeLogStatus,
890
1135
  subscribeSyncStatus,
891
1136
  useConnectivity,
892
1137
  useCrossTabSync,
893
1138
  useLastSynced,
1139
+ useLogConnectivity,
1140
+ useLogStatus,
894
1141
  useStarfish,
895
1142
  useStarfishData,
1143
+ useStarfishLog,
1144
+ useStarfishLogItems,
896
1145
  useSyncInit,
897
1146
  useSyncStatus
898
1147
  };