@drakkar.software/starfish-client 3.0.0-alpha.14 → 3.0.0-alpha.18

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 (45) hide show
  1. package/README.md +16 -0
  2. package/dist/append.d.ts +50 -0
  3. package/dist/bindings/broadcast.d.ts +19 -0
  4. package/dist/bindings/broadcast.js +65 -0
  5. package/dist/bindings/react.d.ts +12 -0
  6. package/dist/bindings/react.js +25 -0
  7. package/dist/bindings/zustand.d.ts +27 -1
  8. package/dist/bindings/zustand.js +126 -8
  9. package/dist/bindings/zustand.js.map +2 -2
  10. package/dist/client.d.ts +27 -0
  11. package/dist/client.js +37 -316
  12. package/dist/crypto.js +49 -0
  13. package/dist/entitlements.js +41 -0
  14. package/dist/group-crypto.d.ts +111 -0
  15. package/dist/group-crypto.js +205 -0
  16. package/dist/group-crypto.js.map +7 -0
  17. package/dist/identity.d.ts +82 -4
  18. package/dist/identity.js +354 -2
  19. package/dist/identity.js.map +4 -4
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +109 -4
  22. package/dist/index.js.map +2 -2
  23. package/dist/mobile-lifecycle.js +2 -41
  24. package/dist/polling.js +2 -2
  25. package/dist/sync.d.ts +18 -0
  26. package/dist/sync.js +14 -68
  27. package/dist/types.d.ts +43 -0
  28. package/package.json +2 -2
  29. package/dist/_crypto_helpers.d.ts +0 -4
  30. package/dist/append-log.js +0 -267
  31. package/dist/cap-mint.d.ts +0 -20
  32. package/dist/cap-mint.js +0 -12
  33. package/dist/cap-mint.js.map +0 -7
  34. package/dist/directory.d.ts +0 -9
  35. package/dist/directory.js +0 -24
  36. package/dist/directory.js.map +0 -7
  37. package/dist/keyring.d.ts +0 -6
  38. package/dist/keyring.js +0 -26
  39. package/dist/keyring.js.map +0 -7
  40. package/dist/pairing.d.ts +0 -6
  41. package/dist/pairing.js +0 -26
  42. package/dist/pairing.js.map +0 -7
  43. package/dist/recipients.d.ts +0 -6
  44. package/dist/recipients.js +0 -16
  45. package/dist/recipients.js.map +0 -7
@@ -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((err) => { console.error("[Starfish] background flush failed:", err); });
34
+ store.getState().flush().catch(() => { });
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((err) => { console.error("[Starfish] foreground pull failed:", err); });
40
+ store.getState().pull().catch(() => { });
41
41
  }
42
42
  }
43
43
  // "inactive" (iOS transition) and other states are intentionally ignored
@@ -53,42 +53,3 @@ 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
- }
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((err) => { console.error("[Starfish] poll failed:", err); });
17
+ pullFn().catch(() => { });
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((err) => { console.error("[Starfish] adaptive poll failed:", err); });
45
+ pullFn().catch(() => { });
46
46
  }, intervalMs);
47
47
  return {
48
48
  pause: () => { paused = true; },
package/dist/sync.d.ts CHANGED
@@ -70,6 +70,7 @@ 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;
@@ -87,6 +88,23 @@ export declare class SyncManager {
87
88
  getHash(): string | null;
88
89
  /** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
89
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>;
90
108
  getCheckpoint(): number;
91
109
  pull(): Promise<PullResult>;
92
110
  push(data: Record<string, unknown>): Promise<{
package/dist/sync.js CHANGED
@@ -1,13 +1,7 @@
1
- import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, deepMerge, docAuthorCanonicalInput, getBase64, } from "@drakkar.software/starfish-protocol";
1
+ import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
2
2
  import { ConflictError } from "./types.js";
3
- import { stripPushPrefix } from "./client.js";
3
+ import { createEncryptor } from "./crypto.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
- }
11
5
  export class SyncManager {
12
6
  client;
13
7
  pullPath;
@@ -15,31 +9,28 @@ export class SyncManager {
15
9
  onConflict;
16
10
  maxRetries;
17
11
  encryptor;
18
- signer;
12
+ signData;
19
13
  logger;
20
14
  loggerName;
21
15
  validate;
22
16
  lastHash = null;
23
17
  lastCheckpoint = 0;
24
18
  localData = {};
25
- aborted = false;
26
19
  constructor(options) {
27
20
  this.client = options.client;
28
21
  this.pullPath = options.pullPath;
29
22
  this.pushPath = options.pushPath;
30
23
  this.onConflict = options.onConflict ?? deepMerge;
31
24
  this.maxRetries = options.maxRetries ?? 3;
32
- this.signer = options.signer;
25
+ this.signData = options.signData;
33
26
  this.logger = options.logger;
34
27
  this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
35
28
  this.validate = options.validate;
36
- this.encryptor = options.encryptor ?? null;
37
- }
38
- abort() {
39
- this.aborted = true;
40
- }
41
- get isAborted() {
42
- return this.aborted;
29
+ this.encryptor =
30
+ options.encryptor ??
31
+ (options.encryptionSecret && options.encryptionSalt
32
+ ? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo)
33
+ : null);
43
34
  }
44
35
  getData() {
45
36
  return { ...this.localData };
@@ -47,31 +38,16 @@ export class SyncManager {
47
38
  getHash() {
48
39
  return this.lastHash;
49
40
  }
50
- /** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
51
- setHash(hash) {
52
- this.lastHash = hash;
53
- }
54
41
  getCheckpoint() {
55
42
  return this.lastCheckpoint;
56
43
  }
57
44
  async pull() {
58
- if (this.aborted)
59
- throw new AbortError();
60
45
  this.logger?.pullStart(this.loggerName);
61
46
  const start = performance.now();
62
47
  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.
68
48
  const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
69
- if (this.aborted)
70
- throw new AbortError();
71
49
  if (this.encryptor) {
72
50
  const decrypted = await this.encryptor.decrypt(result.data);
73
- if (this.aborted)
74
- throw new AbortError();
75
51
  this.localData = decrypted;
76
52
  result.data = decrypted;
77
53
  }
@@ -93,8 +69,6 @@ export class SyncManager {
93
69
  }
94
70
  }
95
71
  async push(data) {
96
- if (this.aborted)
97
- throw new AbortError();
98
72
  if (this.validate) {
99
73
  const result = this.validate(data);
100
74
  if (result !== true)
@@ -106,33 +80,13 @@ export class SyncManager {
106
80
  let pendingData = data;
107
81
  while (attempt <= this.maxRetries) {
108
82
  try {
109
- const sealed = this.encryptor
83
+ const payload = this.encryptor
110
84
  ? await this.encryptor.encrypt(pendingData)
111
85
  : pendingData;
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();
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);
136
90
  this.lastHash = result.hash;
137
91
  this.lastCheckpoint = result.timestamp;
138
92
  this.localData = pendingData;
@@ -140,8 +94,6 @@ export class SyncManager {
140
94
  return result;
141
95
  }
142
96
  catch (err) {
143
- if (err instanceof AbortError)
144
- throw err;
145
97
  if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
146
98
  this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
147
99
  throw err;
@@ -149,20 +101,14 @@ export class SyncManager {
149
101
  this.logger?.conflict(this.loggerName, attempt + 1);
150
102
  try {
151
103
  const remote = await this.client.pull(this.pullPath);
152
- if (this.aborted)
153
- throw new AbortError();
154
104
  const remoteData = this.encryptor
155
105
  ? await this.encryptor.decrypt(remote.data)
156
106
  : remote.data;
157
- if (this.aborted)
158
- throw new AbortError();
159
107
  this.lastHash = remote.hash;
160
108
  this.lastCheckpoint = remote.timestamp;
161
109
  pendingData = this.onConflict(pendingData, remoteData);
162
110
  }
163
111
  catch (resolveErr) {
164
- if (resolveErr instanceof AbortError)
165
- throw resolveErr;
166
112
  const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
167
113
  this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
168
114
  throw resolveErr;
package/dist/types.d.ts CHANGED
@@ -36,6 +36,26 @@ 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"). */
@@ -66,6 +86,29 @@ export interface StarfishClientOptions {
66
86
  capProvider?: StarfishCapProvider;
67
87
  /** Optional fetch implementation (defaults to global fetch). */
68
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;
69
112
  /**
70
113
  * Optional list of client-side plugins. The list is stored on the client
71
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.14",
3
+ "version": "3.0.0-alpha.18",
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.14"
63
+ "@drakkar.software/starfish-protocol": "3.0.0-alpha.18"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@legendapp/state": "^2.0.0",
@@ -1,4 +0,0 @@
1
- export declare function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, lengthBytes?: number): Promise<Uint8Array>;
2
- export declare function bytesToHex(bytes: Uint8Array): string;
3
- export declare function hexToBytes(hex: string): Uint8Array;
4
- export declare function concat(...parts: Uint8Array[]): Uint8Array;
@@ -1,267 +0,0 @@
1
- import { verifyAppendAuthor, } from "@drakkar.software/starfish-protocol";
2
- /** The `/pull/` action prefix; mirrors `PUSH_PATH_PREFIX` for the read side. */
3
- const PULL_PATH_PREFIX = "/pull/";
4
- /** The storage `documentKey` for a pull `path`: the path with the `/pull/`
5
- * action prefix stripped (the namespace lives only in the URL). The author
6
- * signature binds to this key, so a reader re-derives it the same way the
7
- * writer did from `/push/…`. */
8
- function stripPullPrefix(path) {
9
- return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
10
- }
11
- /** Thrown when an append element's author signature fails verification. */
12
- export class AppendAuthorError extends Error {
13
- ts;
14
- constructor(ts) {
15
- super(`append element author verification failed (ts=${ts})`);
16
- this.ts = ts;
17
- this.name = "AppendAuthorError";
18
- }
19
- }
20
- /** Largest `ts` among `items`, or `0` when empty. The checkpoint for an
21
- * append-only log is exactly this — the server returns elements with
22
- * `ts > checkpoint`, and element timestamps are strictly increasing. */
23
- export function checkpointOf(items) {
24
- let max = 0;
25
- for (const it of items)
26
- if (it.ts > max)
27
- max = it.ts;
28
- return max;
29
- }
30
- /** Copy the optional author fields from `src` onto a fresh element with `data`. */
31
- function withAuthor(ts, data, src) {
32
- const out = { ts, data };
33
- if (src.authorPubkey !== undefined)
34
- out.authorPubkey = src.authorPubkey;
35
- if (src.authorSignature !== undefined)
36
- out.authorSignature = src.authorSignature;
37
- return out;
38
- }
39
- /**
40
- * A stateful cursor over an append-only collection. It owns the accumulated
41
- * array of elements and pulls only what is new: each {@link pull} derives the
42
- * checkpoint from the last element it holds and asks the server for elements
43
- * with a greater `ts`.
44
- *
45
- * This is the incremental, stateful counterpart to the deliberately stateless
46
- * `client.pull(path, { appendField, since })`, and the sibling of
47
- * {@link SyncManager} for append-only logs (no merge / push-conflict
48
- * machinery — a log only grows).
49
- *
50
- * The cursor accumulates every pulled element in memory; for an unboundedly
51
- * large log, pull a bounded window with raw `client.pull(path, { last })` instead.
52
- *
53
- * Cold start (nothing persisted) — first `pull()` fetches the whole collection:
54
- * ```ts
55
- * const log = new AppendLogCursor({ client, pullPath: "/pull/events" })
56
- * const all = await log.pull()
57
- * ```
58
- * Warm start (resume from persisted data) — first `pull()` fetches only newer
59
- * elements; persistence is a round-trip of `getItems()`:
60
- * ```ts
61
- * const log = new AppendLogCursor({ client, pullPath: "/pull/events",
62
- * initialItems: await store.load() })
63
- * const fresh = await log.pull()
64
- * await store.save(log.getItems())
65
- * ```
66
- * Warm start for an **E2EE** log — persist ciphertext, render decrypted:
67
- * ```ts
68
- * const log = new AppendLogCursor({ client, pullPath: "/pull/streamchat",
69
- * encryptor, persistEncrypted: true, onElementError: "skip",
70
- * initialItems: await store.load() }) // ciphertext from disk
71
- * const history = await log.getDecryptedItems() // render persisted history
72
- * const fresh = await log.pull() // decrypted delta
73
- * await store.save(log.getItems()) // ciphertext back to disk
74
- * ```
75
- */
76
- export class AppendLogCursor {
77
- client;
78
- pullPath;
79
- appendField;
80
- encryptor;
81
- verifyAuthor;
82
- onElementError;
83
- persistEncrypted;
84
- documentKey;
85
- logger;
86
- loggerName;
87
- items;
88
- lastCheckpoint;
89
- /** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
90
- * it so each runs against the checkpoint the previous one advanced — no two
91
- * overlapping fetches read the same checkpoint and double-append a window. */
92
- pullChain = Promise.resolve();
93
- constructor(options) {
94
- this.client = options.client;
95
- this.pullPath = options.pullPath;
96
- this.appendField = options.appendField ?? "items";
97
- this.encryptor = options.encryptor;
98
- this.verifyAuthor = options.verifyAuthor;
99
- this.onElementError = options.onElementError ?? "throw";
100
- this.persistEncrypted = options.persistEncrypted ?? false;
101
- this.documentKey = stripPullPrefix(options.pullPath);
102
- this.logger = options.logger;
103
- this.loggerName =
104
- options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
105
- const seed = options.initialItems ?? [];
106
- const seedCheckpoint = checkpointOf(seed);
107
- if (options.since != null) {
108
- if (options.since < 0)
109
- throw new Error("since must be non-negative");
110
- if (options.since < seedCheckpoint) {
111
- throw new Error("since must be >= the max ts of initialItems");
112
- }
113
- this.lastCheckpoint = options.since;
114
- }
115
- else {
116
- this.lastCheckpoint = seedCheckpoint;
117
- }
118
- this.items = [...seed];
119
- }
120
- /**
121
- * Fetch elements newer than the current checkpoint, verify + decrypt them,
122
- * append them to the local log, and return ONLY the newly-fetched batch
123
- * (decrypted when an `encryptor` is set).
124
- *
125
- * Atomic under `onElementError: "throw"` (the default): the batch is fully
126
- * verified and decrypted into a local before any state mutation, so a
127
- * verify/decrypt failure throws without advancing the checkpoint past elements
128
- * that could never be re-fetched. Under `"skip"`, a failing element is dropped
129
- * from the returned batch but the checkpoint still advances past it.
130
- *
131
- * Safe to call concurrently: overlapping calls are serialized internally, so
132
- * each runs against the checkpoint the previous one advanced (no double-fetch
133
- * of the same window). The next pull after one completes will pick up anything
134
- * that arrived in between.
135
- */
136
- async pull() {
137
- // Chain onto the previous pull (whether it resolved or rejected) so calls
138
- // run one-at-a-time against the latest checkpoint. `pullChain` swallows
139
- // outcomes to stay alive; the caller still sees this call's real result.
140
- const run = this.pullChain.then(() => this.doPull(), () => this.doPull());
141
- this.pullChain = run.then(() => undefined, () => undefined);
142
- return run;
143
- }
144
- async doPull() {
145
- this.logger?.pullStart(this.loggerName);
146
- const start = performance.now();
147
- try {
148
- const since = this.lastCheckpoint;
149
- // Omit `since` on cold start so the request carries no `?checkpoint=`.
150
- const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField };
151
- const raw = await this.client.pull(this.pullPath, opts);
152
- const batch = []; // decrypted, returned to the caller
153
- const stored = []; // what we keep in `items` (cipher- or plaintext)
154
- let maxTs = since;
155
- let skipped = 0;
156
- for (const el of raw) {
157
- // Defensive: guard a misbehaving/mocked server from making us
158
- // double-append a held element. Gated on `since > 0` to mirror the
159
- // server (which only filters when checkpoint > 0): on a cold start
160
- // `since` is 0 and we must NOT drop a legitimate `ts: 0` first element.
161
- if (since > 0 && el.ts <= since)
162
- continue;
163
- // Advance past every windowed element BEFORE verify/decrypt so a skipped
164
- // element still moves the checkpoint and is never re-fetched.
165
- if (el.ts > maxTs)
166
- maxTs = el.ts;
167
- let decrypted = null;
168
- try {
169
- this.verifyOne(el);
170
- const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
171
- decrypted = withAuthor(el.ts, data, el);
172
- }
173
- catch (err) {
174
- // "throw" rethrows here, before any state mutation below — atomic.
175
- if (this.onElementError !== "skip")
176
- throw err;
177
- skipped++;
178
- }
179
- if (this.persistEncrypted) {
180
- // Keep the original ciphertext envelope (even for a skipped element:
181
- // it is valid data we simply cannot read now — a later key might).
182
- stored.push(withAuthor(el.ts, el.data, el));
183
- }
184
- else if (decrypted) {
185
- stored.push(decrypted);
186
- }
187
- if (decrypted)
188
- batch.push(decrypted);
189
- }
190
- this.items.push(...stored);
191
- this.lastCheckpoint = maxTs;
192
- this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start), skipped > 0 ? { skippedCount: skipped } : undefined);
193
- return batch;
194
- }
195
- catch (err) {
196
- this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
197
- throw err;
198
- }
199
- }
200
- /** Verify a single element's author signature over its RAW (pre-decryption)
201
- * `data`. Throws {@link AppendAuthorError} on any failure. No-op when
202
- * verification is disabled. */
203
- verifyOne(el) {
204
- if (!this.verifyAuthor)
205
- return;
206
- const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
207
- const { authorPubkey, authorSignature } = el;
208
- if (!authorPubkey || !authorSignature)
209
- throw new AppendAuthorError(el.ts);
210
- // Public keys are hex, which is case-insensitive — compare normalized so a
211
- // caller passing a differently-cased `expectedAuthorPubkey` isn't falsely rejected.
212
- if (policy.expectedAuthorPubkey &&
213
- authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
214
- throw new AppendAuthorError(el.ts);
215
- }
216
- void policy;
217
- const ok = verifyAppendAuthor(this.documentKey, el.data, authorPubkey, authorSignature);
218
- if (!ok)
219
- throw new AppendAuthorError(el.ts);
220
- }
221
- /** The full accumulated log (a shallow copy), in `ts` order. Under
222
- * `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
223
- * re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
224
- getItems() {
225
- return [...this.items];
226
- }
227
- /**
228
- * The full accumulated log, DECRYPTED — for rendering warm-started history in
229
- * `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
230
- * `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
231
- * cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
232
- * elements are already plaintext/decrypted and are returned as-is.
233
- */
234
- async getDecryptedItems() {
235
- const snapshot = [...this.items];
236
- if (!this.encryptor || !this.persistEncrypted)
237
- return snapshot;
238
- const out = [];
239
- for (const el of snapshot) {
240
- try {
241
- this.verifyOne(el);
242
- const data = await this.encryptor.decrypt(el.data);
243
- out.push(withAuthor(el.ts, data, el));
244
- }
245
- catch (err) {
246
- if (this.onElementError !== "skip")
247
- throw err;
248
- }
249
- }
250
- return out;
251
- }
252
- /** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
253
- * when nothing has been pulled or seeded. */
254
- getCheckpoint() {
255
- return this.lastCheckpoint;
256
- }
257
- /** Restore the checkpoint without seeding items — for persistence layers that
258
- * store only the checkpoint. Used to resume incrementally across restarts.
259
- * Rejects a value below the max `ts` already held: rewinding would make the
260
- * next pull re-deliver, and duplicate, elements the cursor already has. */
261
- setCheckpoint(ts) {
262
- if (ts < checkpointOf(this.items)) {
263
- throw new Error("checkpoint must be >= the max ts already held");
264
- }
265
- this.lastCheckpoint = ts;
266
- }
267
- }
@@ -1,20 +0,0 @@
1
- /**
2
- * Phase-2 transitional shim. `mintDeviceCap` + `scopes.rootAll` now live in
3
- * `@drakkar.software/starfish-identities`; `mintMemberCap` +
4
- * `scopes.readOnly`/`scopes.writer`/`scopes.admin` now live in
5
- * `@drakkar.software/starfish-sharing`. The `scopes` re-export here merges
6
- * both so existing imports of `import { scopes } from
7
- * "../src/cap-mint.js"` keep working. Removed in Phase 3.
8
- */
9
- import { type ScopePreset as IdentityScopePreset, type MintOpts as IdentityMintOpts } from "@drakkar.software/starfish-identities";
10
- export { mintDeviceCap } from "@drakkar.software/starfish-identities";
11
- export { mintMemberCap } from "@drakkar.software/starfish-sharing";
12
- /** @deprecated Phase-2 transitional facade. Use the per-package `scopes` directly. */
13
- export declare const scopes: {
14
- readOnly: (c: string) => import("@drakkar.software/starfish-sharing").ScopePreset;
15
- writer: (c: string) => import("@drakkar.software/starfish-sharing").ScopePreset;
16
- admin: (c: string) => import("@drakkar.software/starfish-sharing").ScopePreset;
17
- rootAll: () => IdentityScopePreset;
18
- };
19
- export type ScopePreset = IdentityScopePreset;
20
- export type MintOpts = IdentityMintOpts;
package/dist/cap-mint.js DELETED
@@ -1,12 +0,0 @@
1
- // src/cap-mint.ts
2
- import { scopes as identityScopes } from "@drakkar.software/starfish-identities";
3
- import { scopes as sharingScopes } from "@drakkar.software/starfish-sharing";
4
- import { mintDeviceCap } from "@drakkar.software/starfish-identities";
5
- import { mintMemberCap } from "@drakkar.software/starfish-sharing";
6
- var scopes = { ...identityScopes, ...sharingScopes };
7
- export {
8
- mintDeviceCap,
9
- mintMemberCap,
10
- scopes
11
- };
12
- //# sourceMappingURL=cap-mint.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/cap-mint.ts"],
4
- "sourcesContent": ["/**\n * Phase-2 transitional shim. `mintDeviceCap` + `scopes.rootAll` now live in\n * `@drakkar.software/starfish-identities`; `mintMemberCap` +\n * `scopes.readOnly`/`scopes.writer`/`scopes.admin` now live in\n * `@drakkar.software/starfish-sharing`. The `scopes` re-export here merges\n * both so existing imports of `import { scopes } from\n * \"../src/cap-mint.js\"` keep working. Removed in Phase 3.\n */\nimport { scopes as identityScopes, type ScopePreset as IdentityScopePreset, type MintOpts as IdentityMintOpts } from \"@drakkar.software/starfish-identities\"\nimport { scopes as sharingScopes } from \"@drakkar.software/starfish-sharing\"\n\nexport { mintDeviceCap } from \"@drakkar.software/starfish-identities\"\nexport { mintMemberCap } from \"@drakkar.software/starfish-sharing\"\n\n/** @deprecated Phase-2 transitional facade. Use the per-package `scopes` directly. */\nexport const scopes = { ...identityScopes, ...sharingScopes }\n\nexport type ScopePreset = IdentityScopePreset\nexport type MintOpts = IdentityMintOpts\n"],
5
- "mappings": ";AAQA,SAAS,UAAU,sBAAkG;AACrH,SAAS,UAAU,qBAAqB;AAExC,SAAS,qBAAqB;AAC9B,SAAS,qBAAqB;AAGvB,IAAM,SAAS,EAAE,GAAG,gBAAgB,GAAG,cAAc;",
6
- "names": []
7
- }