@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/README.md +21 -0
- package/dist/append-log.d.ts +158 -0
- package/dist/bindings/legend.d.ts +23 -0
- package/dist/bindings/legend.js +32 -0
- package/dist/bindings/legend.js.map +2 -2
- package/dist/bindings/zustand.d.ts +38 -0
- package/dist/bindings/zustand.js +235 -50
- package/dist/bindings/zustand.js.map +3 -3
- package/dist/client.d.ts +60 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +313 -52
- package/dist/index.js.map +4 -4
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/package.json +2 -2
package/dist/bindings/zustand.js
CHANGED
|
@@ -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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
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: {
|
|
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
|
|
402
|
-
*
|
|
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
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
498
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
};
|