@drakkar.software/starfish-client 3.0.0-alpha.2 → 3.0.0-alpha.22

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.
Files changed (53) hide show
  1. package/README.md +44 -0
  2. package/dist/_crypto_helpers.d.ts +4 -0
  3. package/dist/append-log.d.ts +228 -0
  4. package/dist/append-log.js +267 -0
  5. package/dist/bindings/legend.d.ts +23 -0
  6. package/dist/bindings/legend.js +32 -0
  7. package/dist/bindings/legend.js.map +2 -2
  8. package/dist/bindings/zustand.d.ts +72 -1
  9. package/dist/bindings/zustand.js +427 -63
  10. package/dist/bindings/zustand.js.map +3 -3
  11. package/dist/cap-mint.d.ts +20 -0
  12. package/dist/cap-mint.js +12 -0
  13. package/dist/cap-mint.js.map +7 -0
  14. package/dist/client.d.ts +128 -5
  15. package/dist/client.js +316 -37
  16. package/dist/config.d.ts +9 -0
  17. package/dist/directory.d.ts +9 -0
  18. package/dist/directory.js +24 -0
  19. package/dist/directory.js.map +7 -0
  20. package/dist/identity.d.ts +4 -82
  21. package/dist/identity.js +2 -354
  22. package/dist/identity.js.map +4 -4
  23. package/dist/index.d.ts +9 -5
  24. package/dist/index.js +578 -60
  25. package/dist/index.js.map +4 -4
  26. package/dist/keyring.d.ts +6 -0
  27. package/dist/keyring.js +26 -0
  28. package/dist/keyring.js.map +7 -0
  29. package/dist/logger.d.ts +3 -0
  30. package/dist/mobile-lifecycle.d.ts +28 -1
  31. package/dist/mobile-lifecycle.js +41 -2
  32. package/dist/mutate.d.ts +39 -0
  33. package/dist/pairing.d.ts +6 -0
  34. package/dist/pairing.js +26 -0
  35. package/dist/pairing.js.map +7 -0
  36. package/dist/polling.js +2 -2
  37. package/dist/recipients.d.ts +6 -0
  38. package/dist/recipients.js +16 -0
  39. package/dist/recipients.js.map +7 -0
  40. package/dist/sync.d.ts +28 -0
  41. package/dist/sync.js +68 -14
  42. package/dist/types.d.ts +62 -0
  43. package/package.json +2 -2
  44. package/dist/append.d.ts +0 -50
  45. package/dist/bindings/broadcast.d.ts +0 -19
  46. package/dist/bindings/broadcast.js +0 -65
  47. package/dist/bindings/react.d.ts +0 -12
  48. package/dist/bindings/react.js +0 -25
  49. package/dist/crypto.js +0 -49
  50. package/dist/entitlements.js +0 -41
  51. package/dist/group-crypto.d.ts +0 -111
  52. package/dist/group-crypto.js +0 -205
  53. package/dist/group-crypto.js.map +0 -7
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Phase-2 transitional shim. The implementation lives in
3
+ * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.
4
+ */
5
+ export { KEYRING_WRAP_SALT, KEYRING_WRAP_INFO, KEYRING_IV_BYTES, wrapForRecipient, unwrapFromEntry, verifyEntrySignature, createKeyring, addRecipient, rotateEpoch, createKeyringEncryptor, } from "@drakkar.software/starfish-keyring";
6
+ export type { WrappedKeyEntry, KeyringEpoch, Keyring, KeyringEncryptor, } from "@drakkar.software/starfish-keyring";
@@ -0,0 +1,26 @@
1
+ // src/keyring.ts
2
+ import {
3
+ KEYRING_WRAP_SALT,
4
+ KEYRING_WRAP_INFO,
5
+ KEYRING_IV_BYTES,
6
+ wrapForRecipient,
7
+ unwrapFromEntry,
8
+ verifyEntrySignature,
9
+ createKeyring,
10
+ addRecipient,
11
+ rotateEpoch,
12
+ createKeyringEncryptor
13
+ } from "@drakkar.software/starfish-keyring";
14
+ export {
15
+ KEYRING_IV_BYTES,
16
+ KEYRING_WRAP_INFO,
17
+ KEYRING_WRAP_SALT,
18
+ addRecipient,
19
+ createKeyring,
20
+ createKeyringEncryptor,
21
+ rotateEpoch,
22
+ unwrapFromEntry,
23
+ verifyEntrySignature,
24
+ wrapForRecipient
25
+ };
26
+ //# sourceMappingURL=keyring.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/keyring.ts"],
4
+ "sourcesContent": ["/**\n * Phase-2 transitional shim. The implementation lives in\n * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.\n */\nexport {\n KEYRING_WRAP_SALT,\n KEYRING_WRAP_INFO,\n KEYRING_IV_BYTES,\n wrapForRecipient,\n unwrapFromEntry,\n verifyEntrySignature,\n createKeyring,\n addRecipient,\n rotateEpoch,\n createKeyringEncryptor,\n} from \"@drakkar.software/starfish-keyring\"\nexport type {\n WrappedKeyEntry,\n KeyringEpoch,\n Keyring,\n KeyringEncryptor,\n} from \"@drakkar.software/starfish-keyring\"\n"],
5
+ "mappings": ";AAIA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
6
+ "names": []
7
+ }
package/dist/logger.d.ts CHANGED
@@ -5,6 +5,9 @@ export interface SyncMetrics {
5
5
  conflictCount?: number;
6
6
  retryCount?: number;
7
7
  cacheHit?: boolean;
8
+ /** Elements an append-log pull dropped under `onElementError: "skip"`
9
+ * (failed verification/decryption). Omitted when none were skipped. */
10
+ skippedCount?: number;
8
11
  }
9
12
  /** Structured logger for sync operations. */
10
13
  export interface SyncLogger {
@@ -1,5 +1,5 @@
1
1
  import type { StoreApi } from "zustand/vanilla";
2
- import type { StarfishStore } from "./bindings/zustand.js";
2
+ import type { StarfishStore, StarfishLogStore } from "./bindings/zustand.js";
3
3
  /**
4
4
  * Minimal interface matching React Native's `AppState` module.
5
5
  * Pass `AppState` from `react-native` directly.
@@ -69,3 +69,30 @@ export interface MobileLifecycleOptions {
69
69
  * @returns A cleanup function that removes all event listeners.
70
70
  */
71
71
  export declare function createMobileLifecycle(store: StoreApi<StarfishStore>, deps: MobileLifecycleDeps, options?: MobileLifecycleOptions): () => void;
72
+ export interface AppendLogLifecycleOptions {
73
+ /**
74
+ * Pull new elements when the app returns to the foreground.
75
+ * Only pulls if the store is online and not already loading.
76
+ * Default: `true`.
77
+ */
78
+ pullOnForeground?: boolean;
79
+ }
80
+ /**
81
+ * Wires React Native app lifecycle events to an append-log store
82
+ * (`createStarfishLog`). A log is read-only, so this only pulls on foreground
83
+ * (there is nothing to flush on background). NetInfo connectivity changes are
84
+ * forwarded to `store.getState().setOnline()`.
85
+ *
86
+ * ```ts
87
+ * import { AppState } from "react-native"
88
+ * import NetInfo from "@react-native-community/netinfo"
89
+ * import { createStarfishLog, createAppendLogMobileLifecycle } from "@drakkar.software/starfish-client"
90
+ *
91
+ * const store = createStarfishLog({ cursor })
92
+ * const cleanup = createAppendLogMobileLifecycle(store, { appState: AppState, netInfo: NetInfo })
93
+ * useEffect(() => cleanup, [])
94
+ * ```
95
+ *
96
+ * @returns A cleanup function that removes all event listeners.
97
+ */
98
+ export declare function createAppendLogMobileLifecycle(store: StoreApi<StarfishLogStore>, deps: MobileLifecycleDeps, options?: AppendLogLifecycleOptions): () => void;
@@ -31,13 +31,13 @@ export function createMobileLifecycle(store, deps, options = {}) {
31
31
  const appSub = deps.appState.addEventListener("change", (appState) => {
32
32
  if (appState === "background" && flushOnBackground) {
33
33
  if (store.getState().dirty) {
34
- store.getState().flush().catch(() => { });
34
+ store.getState().flush().catch((err) => { console.error("[Starfish] background flush failed:", err); });
35
35
  }
36
36
  }
37
37
  else if (appState === "active" && pullOnForeground) {
38
38
  const { online, syncing } = store.getState();
39
39
  if (online && !syncing) {
40
- store.getState().pull().catch(() => { });
40
+ store.getState().pull().catch((err) => { console.error("[Starfish] foreground pull failed:", err); });
41
41
  }
42
42
  }
43
43
  // "inactive" (iOS transition) and other states are intentionally ignored
@@ -53,3 +53,42 @@ export function createMobileLifecycle(store, deps, options = {}) {
53
53
  netUnsub?.();
54
54
  };
55
55
  }
56
+ /**
57
+ * Wires React Native app lifecycle events to an append-log store
58
+ * (`createStarfishLog`). A log is read-only, so this only pulls on foreground
59
+ * (there is nothing to flush on background). NetInfo connectivity changes are
60
+ * forwarded to `store.getState().setOnline()`.
61
+ *
62
+ * ```ts
63
+ * import { AppState } from "react-native"
64
+ * import NetInfo from "@react-native-community/netinfo"
65
+ * import { createStarfishLog, createAppendLogMobileLifecycle } from "@drakkar.software/starfish-client"
66
+ *
67
+ * const store = createStarfishLog({ cursor })
68
+ * const cleanup = createAppendLogMobileLifecycle(store, { appState: AppState, netInfo: NetInfo })
69
+ * useEffect(() => cleanup, [])
70
+ * ```
71
+ *
72
+ * @returns A cleanup function that removes all event listeners.
73
+ */
74
+ export function createAppendLogMobileLifecycle(store, deps, options = {}) {
75
+ const { pullOnForeground = true } = options;
76
+ const appSub = deps.appState.addEventListener("change", (appState) => {
77
+ if (appState === "active" && pullOnForeground) {
78
+ const { online, loading } = store.getState();
79
+ if (online && !loading) {
80
+ store.getState().pull().catch((err) => { console.error("[Starfish] foreground log pull failed:", err); });
81
+ }
82
+ }
83
+ });
84
+ let netUnsub = null;
85
+ if (deps.netInfo) {
86
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
87
+ store.getState().setOnline(!!isConnected);
88
+ });
89
+ }
90
+ return () => {
91
+ appSub.remove();
92
+ netUnsub?.();
93
+ };
94
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Read-modify-write a document with hash-CAS conflict retry.
3
+ *
4
+ * The everyday way to atomically edit a synced document: pull the current
5
+ * version, apply a pure `mutator` to its data, push the result with the read
6
+ * hash, and retry on a {@link ConflictError} (a concurrent writer moved the hash)
7
+ * by re-reading FRESH server state and re-applying the mutator. A missing
8
+ * document (404) is surfaced to the mutator as `{ data: null, hash: null }` so it
9
+ * can create the doc on first write.
10
+ *
11
+ * This replaces the ad-hoc `for (attempt…) { pull; mutate; try push catch
12
+ * ConflictError }` loop that applications otherwise hand-roll around every
13
+ * editable doc. The `mutator` MUST be idempotent — it re-runs on each retry — and
14
+ * returns `null` to signal a no-op (nothing changed; skip the write).
15
+ */
16
+ import { StarfishClient } from "./client.js";
17
+ /** The current state handed to a {@link DocMutator}: the document data (or `null`
18
+ * when the doc does not exist yet) and the hash to base the next push on. */
19
+ export interface DocState<T> {
20
+ data: T | null;
21
+ hash: string | null;
22
+ }
23
+ /**
24
+ * Pure transform from the current document to the next. Return the full next
25
+ * document body to write, or `null` for a no-op (the write is skipped). Runs once
26
+ * per attempt on freshly-pulled state, so it must be idempotent.
27
+ */
28
+ export type DocMutator<T> = (cur: DocState<T>) => T | null;
29
+ export interface MutateDocOptions {
30
+ /** Max push attempts before a persistent conflict propagates. Default 3. */
31
+ maxAttempts?: number;
32
+ }
33
+ /**
34
+ * Atomically read-modify-write the document at `path`. Returns the document that
35
+ * was written, or `null` if the mutator signalled a no-op. Throws the underlying
36
+ * error on a non-conflict failure, or a {@link ConflictError} if every attempt
37
+ * raced and lost.
38
+ */
39
+ export declare function mutateDoc<T extends Record<string, unknown> = Record<string, unknown>>(client: StarfishClient, path: string, mutator: DocMutator<T>, options?: MutateDocOptions): Promise<T | null>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Phase-2 transitional shim. The implementation lives in
3
+ * `@drakkar.software/starfish-identities`. Removed in Phase 3.
4
+ */
5
+ export { bootstrapRootIdentity, buildPairingQr, parsePairingQr, assemblePairingBundle, installPairingBundle, deriveCodeKey, buildPairingRequest, readPairingRequest, buildPairingResponse, readPairingResponse, } from "@drakkar.software/starfish-identities";
6
+ export type { DeviceCredentials, PairingQrPayload, PairingBundle, WrappedCekEntry, InstalledPairingResult, AssemblePairingBundleOpts, PairingRequestEncrypted, PairingResponseEncrypted, } from "@drakkar.software/starfish-identities";
@@ -0,0 +1,26 @@
1
+ // src/pairing.ts
2
+ import {
3
+ bootstrapRootIdentity,
4
+ buildPairingQr,
5
+ parsePairingQr,
6
+ assemblePairingBundle,
7
+ installPairingBundle,
8
+ deriveCodeKey,
9
+ buildPairingRequest,
10
+ readPairingRequest,
11
+ buildPairingResponse,
12
+ readPairingResponse
13
+ } from "@drakkar.software/starfish-identities";
14
+ export {
15
+ assemblePairingBundle,
16
+ bootstrapRootIdentity,
17
+ buildPairingQr,
18
+ buildPairingRequest,
19
+ buildPairingResponse,
20
+ deriveCodeKey,
21
+ installPairingBundle,
22
+ parsePairingQr,
23
+ readPairingRequest,
24
+ readPairingResponse
25
+ };
26
+ //# sourceMappingURL=pairing.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/pairing.ts"],
4
+ "sourcesContent": ["/**\n * Phase-2 transitional shim. The implementation lives in\n * `@drakkar.software/starfish-identities`. Removed in Phase 3.\n */\nexport {\n bootstrapRootIdentity,\n buildPairingQr,\n parsePairingQr,\n assemblePairingBundle,\n installPairingBundle,\n deriveCodeKey,\n buildPairingRequest,\n readPairingRequest,\n buildPairingResponse,\n readPairingResponse,\n} from \"@drakkar.software/starfish-identities\"\nexport type {\n DeviceCredentials,\n PairingQrPayload,\n PairingBundle,\n WrappedCekEntry,\n InstalledPairingResult,\n AssemblePairingBundleOpts,\n PairingRequestEncrypted,\n PairingResponseEncrypted,\n} from \"@drakkar.software/starfish-identities\"\n"],
5
+ "mappings": ";AAIA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
6
+ "names": []
7
+ }
package/dist/polling.js CHANGED
@@ -14,7 +14,7 @@ export function startPolling(pullFn, getState, intervalMs = 30_000) {
14
14
  const timer = setInterval(() => {
15
15
  const { online, syncing } = getState();
16
16
  if (online && !syncing)
17
- pullFn().catch(() => { });
17
+ pullFn().catch((err) => { console.error("[Starfish] poll failed:", err); });
18
18
  }, intervalMs);
19
19
  return () => clearInterval(timer);
20
20
  }
@@ -42,7 +42,7 @@ export function startAdaptivePolling(pullFn, getState, options) {
42
42
  return;
43
43
  const { online, syncing } = getState();
44
44
  if (online && !syncing)
45
- pullFn().catch(() => { });
45
+ pullFn().catch((err) => { console.error("[Starfish] adaptive poll failed:", err); });
46
46
  }, intervalMs);
47
47
  return {
48
48
  pause: () => { paused = true; },
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Phase-2 transitional shim. The implementation lives in
3
+ * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.
4
+ */
5
+ export { keyringPathFor, addCollectionRecipient, removeRecipient, listRecipients, currentEpoch, } from "@drakkar.software/starfish-keyring";
6
+ export type { RecipientRef, AdderKeys, ListedRecipient, } from "@drakkar.software/starfish-keyring";
@@ -0,0 +1,16 @@
1
+ // src/recipients.ts
2
+ import {
3
+ keyringPathFor,
4
+ addCollectionRecipient,
5
+ removeRecipient,
6
+ listRecipients,
7
+ currentEpoch
8
+ } from "@drakkar.software/starfish-keyring";
9
+ export {
10
+ addCollectionRecipient,
11
+ currentEpoch,
12
+ keyringPathFor,
13
+ listRecipients,
14
+ removeRecipient
15
+ };
16
+ //# sourceMappingURL=recipients.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/recipients.ts"],
4
+ "sourcesContent": ["/**\n * Phase-2 transitional shim. The implementation lives in\n * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.\n */\nexport {\n keyringPathFor,\n addCollectionRecipient,\n removeRecipient,\n listRecipients,\n currentEpoch,\n} from \"@drakkar.software/starfish-keyring\"\nexport type {\n RecipientRef,\n AdderKeys,\n ListedRecipient,\n} from \"@drakkar.software/starfish-keyring\"\n"],
5
+ "mappings": ";AAIA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
6
+ "names": []
7
+ }
package/dist/sync.d.ts CHANGED
@@ -70,13 +70,41 @@ export declare class SyncManager {
70
70
  private lastCheckpoint;
71
71
  private localData;
72
72
  private aborted;
73
+ private lastFromCache;
73
74
  constructor(options: SyncManagerOptions);
74
75
  abort(): void;
75
76
  get isAborted(): boolean;
76
77
  getData(): Record<string, unknown>;
78
+ /**
79
+ * Merge a remote snapshot with local (optimistic) data using this manager's
80
+ * conflict resolver — the same resolver the push-conflict path uses. A plain
81
+ * {@link pull} overwrites the store's data with the server snapshot, which
82
+ * would drop un-pushed local writes (they live only in the store, never in
83
+ * `localData` until a push succeeds). The zustand binding calls this on pull
84
+ * while the store is dirty so those writes survive. `local` wins by the same
85
+ * rules as a push conflict.
86
+ */
87
+ resolve(local: Record<string, unknown>, remote: Record<string, unknown>): Record<string, unknown>;
77
88
  getHash(): string | null;
78
89
  /** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
79
90
  setHash(hash: string | null): void;
91
+ /**
92
+ * Whether the most recent {@link pull} (or {@link seedFromCache}) was served
93
+ * from the client's offline read-through cache rather than a live server
94
+ * response. The binding surfaces this as a `stale` flag so the UI can show an
95
+ * offline indicator without treating a cache hit as "reachable". Reset to
96
+ * false by the next successful network pull.
97
+ */
98
+ getLastPullFromCache(): boolean;
99
+ /**
100
+ * Cache-first paint: seed `localData` from the client's read-through cache
101
+ * WITHOUT touching the network, decrypting in memory for E2E collections.
102
+ * Returns whether anything was seeded (false on a miss, an expired entry, or
103
+ * a decrypt failure — e.g. keyring skew). Call once on store creation before
104
+ * the initial live {@link pull}, which then supersedes the seeded snapshot.
105
+ * Requires the client to have been built with a `cache`.
106
+ */
107
+ seedFromCache(): Promise<boolean>;
80
108
  getCheckpoint(): number;
81
109
  pull(): Promise<PullResult>;
82
110
  push(data: Record<string, unknown>): Promise<{
package/dist/sync.js CHANGED
@@ -1,7 +1,13 @@
1
- import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
1
+ import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, deepMerge, docAuthorCanonicalInput, getBase64, } from "@drakkar.software/starfish-protocol";
2
2
  import { ConflictError } from "./types.js";
3
- import { createEncryptor } from "./crypto.js";
3
+ import { stripPushPrefix } from "./client.js";
4
4
  import { ValidationError } from "./validate.js";
5
+ export class AbortError extends Error {
6
+ constructor() {
7
+ super("SyncManager was aborted");
8
+ this.name = "AbortError";
9
+ }
10
+ }
5
11
  export class SyncManager {
6
12
  client;
7
13
  pullPath;
@@ -9,28 +15,31 @@ export class SyncManager {
9
15
  onConflict;
10
16
  maxRetries;
11
17
  encryptor;
12
- signData;
18
+ signer;
13
19
  logger;
14
20
  loggerName;
15
21
  validate;
16
22
  lastHash = null;
17
23
  lastCheckpoint = 0;
18
24
  localData = {};
25
+ aborted = false;
19
26
  constructor(options) {
20
27
  this.client = options.client;
21
28
  this.pullPath = options.pullPath;
22
29
  this.pushPath = options.pushPath;
23
30
  this.onConflict = options.onConflict ?? deepMerge;
24
31
  this.maxRetries = options.maxRetries ?? 3;
25
- this.signData = options.signData;
32
+ this.signer = options.signer;
26
33
  this.logger = options.logger;
27
34
  this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
28
35
  this.validate = options.validate;
29
- this.encryptor =
30
- options.encryptor ??
31
- (options.encryptionSecret && options.encryptionSalt
32
- ? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo)
33
- : null);
36
+ this.encryptor = options.encryptor ?? null;
37
+ }
38
+ abort() {
39
+ this.aborted = true;
40
+ }
41
+ get isAborted() {
42
+ return this.aborted;
34
43
  }
35
44
  getData() {
36
45
  return { ...this.localData };
@@ -38,16 +47,31 @@ export class SyncManager {
38
47
  getHash() {
39
48
  return this.lastHash;
40
49
  }
50
+ /** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
51
+ setHash(hash) {
52
+ this.lastHash = hash;
53
+ }
41
54
  getCheckpoint() {
42
55
  return this.lastCheckpoint;
43
56
  }
44
57
  async pull() {
58
+ if (this.aborted)
59
+ throw new AbortError();
45
60
  this.logger?.pullStart(this.loggerName);
46
61
  const start = performance.now();
47
62
  try {
63
+ // NOTE: `SyncManager.pull` does NOT auto-enable `withKeyring`. Clients
64
+ // that drive the keyring helpers from `recipients.ts` and want to save
65
+ // the cold-start round-trip should call `client.pull(path, {withKeyring: true})`
66
+ // directly. We keep `SyncManager` keyring-agnostic so it stays usable
67
+ // for collections that don't use delegated encryption.
48
68
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
69
+ if (this.aborted)
70
+ throw new AbortError();
49
71
  if (this.encryptor) {
50
72
  const decrypted = await this.encryptor.decrypt(result.data);
73
+ if (this.aborted)
74
+ throw new AbortError();
51
75
  this.localData = decrypted;
52
76
  result.data = decrypted;
53
77
  }
@@ -69,6 +93,8 @@ export class SyncManager {
69
93
  }
70
94
  }
71
95
  async push(data) {
96
+ if (this.aborted)
97
+ throw new AbortError();
72
98
  if (this.validate) {
73
99
  const result = this.validate(data);
74
100
  if (result !== true)
@@ -80,13 +106,33 @@ export class SyncManager {
80
106
  let pendingData = data;
81
107
  while (attempt <= this.maxRetries) {
82
108
  try {
83
- const payload = this.encryptor
109
+ const sealed = this.encryptor
84
110
  ? await this.encryptor.encrypt(pendingData)
85
111
  : pendingData;
86
- const sig = this.signData
87
- ? await this.signData(stableStringify(payload))
88
- : undefined;
89
- const result = await this.client.push(this.pushPath, payload, this.lastHash, sig);
112
+ if (this.aborted)
113
+ throw new AbortError();
114
+ // v3.0 signer path: sign the document author proof over the doc-author
115
+ // canonical input (domain-tagged, bound to documentKey) and pass it as
116
+ // top-level body siblings of `data` (NOT inside `data`), where the server
117
+ // verifies it and stores the raw author pubkey.
118
+ let author;
119
+ if (this.signer) {
120
+ const { devEdPubHex, sign } = await this.signer.getSigner();
121
+ if (this.aborted)
122
+ throw new AbortError();
123
+ const documentKey = stripPushPrefix(this.pushPath);
124
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
125
+ const sigBytes = await sign(new TextEncoder().encode(canonical));
126
+ if (this.aborted)
127
+ throw new AbortError();
128
+ author = {
129
+ [AUTHOR_PUBKEY_FIELD]: devEdPubHex,
130
+ [AUTHOR_SIGNATURE_FIELD]: getBase64().encode(sigBytes),
131
+ };
132
+ }
133
+ const result = await this.client.push(this.pushPath, sealed, this.lastHash, author);
134
+ if (this.aborted)
135
+ throw new AbortError();
90
136
  this.lastHash = result.hash;
91
137
  this.lastCheckpoint = result.timestamp;
92
138
  this.localData = pendingData;
@@ -94,6 +140,8 @@ export class SyncManager {
94
140
  return result;
95
141
  }
96
142
  catch (err) {
143
+ if (err instanceof AbortError)
144
+ throw err;
97
145
  if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
98
146
  this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
99
147
  throw err;
@@ -101,14 +149,20 @@ export class SyncManager {
101
149
  this.logger?.conflict(this.loggerName, attempt + 1);
102
150
  try {
103
151
  const remote = await this.client.pull(this.pullPath);
152
+ if (this.aborted)
153
+ throw new AbortError();
104
154
  const remoteData = this.encryptor
105
155
  ? await this.encryptor.decrypt(remote.data)
106
156
  : remote.data;
157
+ if (this.aborted)
158
+ throw new AbortError();
107
159
  this.lastHash = remote.hash;
108
160
  this.lastCheckpoint = remote.timestamp;
109
161
  pendingData = this.onConflict(pendingData, remoteData);
110
162
  }
111
163
  catch (resolveErr) {
164
+ if (resolveErr instanceof AbortError)
165
+ throw resolveErr;
112
166
  const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
113
167
  this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
114
168
  throw resolveErr;
package/dist/types.d.ts CHANGED
@@ -36,10 +36,49 @@ export interface StarfishCapProvider {
36
36
  pubHex?: string;
37
37
  }>;
38
38
  }
39
+ /**
40
+ * A minimal async key-value store the client uses as a read-through cache for
41
+ * {@link StarfishClient.pull} (offline-first reads). Host-provided so the SDK
42
+ * stays storage-agnostic — back it by `localStorage`, `AsyncStorage`, a file,
43
+ * etc. Shaped like a subset of zustand's `StateStorage` so an existing adapter
44
+ * fits.
45
+ *
46
+ * IMPORTANT — what gets stored: the client caches the RAW server response only
47
+ * (`data`/`hash`/`timestamp`). For E2E (`delegated`) collections that payload is
48
+ * the SEALED ciphertext the server holds — never the decrypted form — so this
49
+ * cache is ciphertext-at-rest by construction. Decryption always happens in
50
+ * memory on read (see {@link SyncManager}). Public/plaintext collections cache
51
+ * their plaintext, exactly as the server stores it.
52
+ */
53
+ export interface PullCache {
54
+ /** Return the previously-stored string for `key`, or null if absent. Must not throw. */
55
+ get(key: string): Promise<string | null>;
56
+ /** Store `value` under `key`. Must not throw (failures are swallowed by the client). */
57
+ set(key: string, value: string): Promise<void>;
58
+ }
39
59
  /** Options for creating a StarfishClient. */
40
60
  export interface StarfishClientOptions {
41
61
  /** Base URL of the Starfish server (e.g. "https://api.example.com/v1"). */
42
62
  baseUrl: string;
63
+ /**
64
+ * Optional namespace for a namespace-mounted server. When set, every request
65
+ * path `/{action}/…` is rewritten to `/v1/{namespace}/{action}/…` for BOTH the
66
+ * URL the client hits AND the canonical path it signs, so the signature the
67
+ * server reconstructs from the namespaced URL verifies (no rewrite layer
68
+ * needed). Mirrors the Python client's `namespace` parameter.
69
+ *
70
+ * Crucially this also rewrites the paths that namespace-unaware SDK helpers
71
+ * build internally (e.g. `starfish-keyring`'s `addCollectionRecipient`, blob
72
+ * uploads), so consumers no longer hand-prefix paths or wrap the client to
73
+ * reach a namespaced deployment. Leave unset (default) for a root-mounted
74
+ * server — paths pass through unchanged, byte-identical to before.
75
+ *
76
+ * Pass the bare namespace name (e.g. `"octochat"`); `baseUrl` then carries only
77
+ * the origin (and any reverse-proxy mount the proxy strips), not the `/v1`
78
+ * version segment. Must match `[A-Za-z0-9_-]+` and not be a reserved route name
79
+ * (`pull`, `push`, `health`, `batch`).
80
+ */
81
+ namespace?: string;
43
82
  /**
44
83
  * Cap-cert provider. When set, requests are signed with Ed25519 and carry
45
84
  * `Authorization: Cap <…>`. Omit for unauthenticated public-read collections.
@@ -47,6 +86,29 @@ export interface StarfishClientOptions {
47
86
  capProvider?: StarfishCapProvider;
48
87
  /** Optional fetch implementation (defaults to global fetch). */
49
88
  fetch?: typeof fetch;
89
+ /**
90
+ * Optional read-through cache for {@link StarfishClient.pull} — the basis for
91
+ * offline-first reads. When set, every successful non-append pull is written
92
+ * through to the cache (keyed by document path), and a pull that fails because
93
+ * the TRANSPORT is unreachable (offline / DNS / timeout — `fetch` rejects)
94
+ * falls back to the cached response, tagged so callers can tell it's stale.
95
+ *
96
+ * A real HTTP error (404/403/5xx) is a genuine server answer and always
97
+ * propagates — the cache is NOT consulted — so "no document yet" and
98
+ * "access denied" keep their meaning. Caches ciphertext for E2E collections
99
+ * (the server only ever holds sealed payloads); never decrypted data.
100
+ */
101
+ cache?: PullCache;
102
+ /**
103
+ * Optional max age (ms) for {@link cache} entries. An entry older than this is
104
+ * treated as a cache MISS on every read — both cache-first paint and the
105
+ * offline fallback — so a stale-beyond-policy snapshot is never served (the
106
+ * pull then goes to the network, or rethrows the transport error offline).
107
+ * Each cached snapshot records its write time; expiry is `now - cachedAt >
108
+ * cacheMaxAgeMs`. Omit (default) for entries that never expire — recommended
109
+ * for an offline-first app where any last-synced data beats none.
110
+ */
111
+ cacheMaxAgeMs?: number;
50
112
  /**
51
113
  * Optional list of client-side plugins. The list is stored on the client
52
114
  * instance but does not fire any hooks yet — the contract is plumbed so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakkar.software/starfish-client",
3
- "version": "3.0.0-alpha.2",
3
+ "version": "3.0.0-alpha.22",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Drakkar-Software/starfish.git",
@@ -60,7 +60,7 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@drakkar.software/starfish-protocol": "3.0.0-alpha.2"
63
+ "@drakkar.software/starfish-protocol": "3.0.0-alpha.22"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@legendapp/state": "^2.0.0",
package/dist/append.d.ts DELETED
@@ -1,50 +0,0 @@
1
- import type { StarfishClient } from "./client.js";
2
- import type { PushSuccess } from "@drakkar.software/starfish-protocol";
3
- /**
4
- * Appends `item` to an append-only collection.
5
- *
6
- * Sends `{ data: item, baseHash: null }` — the server ignores `baseHash` for
7
- * append-only collections (conflict detection is disabled or delegated to
8
- * `checkLastItem` on the server side).
9
- *
10
- * ```ts
11
- * await pushAppend(client, "/push/events", { type: "click", ts: Date.now() })
12
- * ```
13
- */
14
- export declare function pushAppend(client: StarfishClient, path: string, item: Record<string, unknown>): Promise<PushSuccess>;
15
- export interface PullAppendListOptions {
16
- /** Array field name. Defaults to `"items"` (server default). */
17
- field?: string;
18
- /**
19
- * Return only items appended after this timestamp (milliseconds since epoch).
20
- * Sent as `?checkpoint=<since>`. Omit for a full pull.
21
- */
22
- since?: number;
23
- /**
24
- * Return only the last K items. Applied after the `since` filter.
25
- * Useful for "latest N entries" queries without a tracked checkpoint.
26
- * Sent as `?last=<K>`.
27
- */
28
- last?: number;
29
- }
30
- /**
31
- * Pulls the stored item array from an append-only collection.
32
- *
33
- * Returns `data[field]` filtered to an array; returns `[]` when the document
34
- * does not exist yet or the field is absent / not an array.
35
- *
36
- * Pass `{ since: ts }` for incremental pulls — only items appended after `ts`
37
- * are returned (requires per-item timestamps on the server, available from 2.0.0).
38
- *
39
- * ```ts
40
- * // Full pull
41
- * const events = await pullAppendList(client, "/pull/events")
42
- *
43
- * // Incremental pull
44
- * const newEvents = await pullAppendList(client, "/pull/events", { since: lastSyncTs })
45
- *
46
- * // Custom field name
47
- * const logs = await pullAppendList(client, "/pull/audit", { field: "logs" })
48
- * ```
49
- */
50
- export declare function pullAppendList<T = unknown>(client: StarfishClient, path: string, options?: PullAppendListOptions): Promise<T[]>;