@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.
- package/README.md +27 -0
- package/dist/append-log.d.ts +230 -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 +45 -0
- package/dist/bindings/zustand.js +296 -47
- package/dist/bindings/zustand.js.map +3 -3
- package/dist/client.d.ts +95 -3
- package/dist/config.d.ts +2 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +442 -49
- package/dist/index.js.map +4 -4
- package/dist/logger.d.ts +3 -0
- package/dist/mobile-lifecycle.d.ts +28 -1
- package/dist/types.d.ts +28 -1
- package/package.json +2 -2
package/dist/config.d.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
export type EncryptionMode = "none" | "delegated";
|
|
3
3
|
/** Append-only configuration exposed via GET /config. */
|
|
4
4
|
export interface AppendOnlyClientInfo {
|
|
5
|
+
/** Append-only strategy. Only `"by_timestamp"` is currently supported. */
|
|
6
|
+
type: "by_timestamp";
|
|
5
7
|
/** Array field name in the stored document. Defaults to "items". */
|
|
6
8
|
field?: string;
|
|
7
9
|
/** false = no storage write (replaces queueOnly). true/absent = append to array. */
|
|
8
10
|
persist?: boolean;
|
|
9
|
-
/** When true, server validates client's baseHash against hash(lastItem). */
|
|
10
|
-
checkLastItem?: boolean;
|
|
11
11
|
}
|
|
12
12
|
/** Per-collection metadata returned by GET /config. */
|
|
13
13
|
export interface CollectionClientInfo {
|
package/dist/index.d.ts
CHANGED
|
@@ -5,9 +5,11 @@ export { buildRevocationList, revocationListCanonicalSigningInput } from "@drakk
|
|
|
5
5
|
export type { RevocationList, RevocationEntry, RevokedSubject, BuildRevocationListOpts, } from "@drakkar.software/starfish-protocol";
|
|
6
6
|
export type { PullResult, PushSuccess, PullKeyringProjection } from "@drakkar.software/starfish-protocol";
|
|
7
7
|
export { StarfishClient } from "./client.js";
|
|
8
|
-
export type { BlobPullResult, BlobPushResult, AppendPullOptions, PullOptions } from "./client.js";
|
|
8
|
+
export type { BlobPullResult, BlobPushResult, AppendPullOptions, PullOptions, BatchPullOptions, BatchPullResult, BatchPullEntry, } from "./client.js";
|
|
9
9
|
export { SyncManager, AbortError } from "./sync.js";
|
|
10
10
|
export type { SyncManagerOptions, SyncSigner } from "./sync.js";
|
|
11
|
+
export { AppendLogCursor, AppendAuthorError, checkpointOf } from "./append-log.js";
|
|
12
|
+
export type { AppendLogCursorOptions, AppendElement, AuthorVerifier, ElementErrorPolicy } from "./append-log.js";
|
|
11
13
|
export { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
|
|
12
14
|
export type { Encryptor } from "@drakkar.software/starfish-protocol";
|
|
13
15
|
export { ConflictError, StarfishHttpError, } from "./types.js";
|
|
@@ -40,8 +42,8 @@ export type { ServiceWorkerOptions } from "./service-worker.js";
|
|
|
40
42
|
export { createSuspenseResource } from "./bindings/suspense.js";
|
|
41
43
|
export { createDebouncedSync, createDebouncedPush } from "./debounced-sync.js";
|
|
42
44
|
export type { DebouncedSyncOptions, DebouncedSync, DebouncedPushOptions, DebouncedPush } from "./debounced-sync.js";
|
|
43
|
-
export { createMobileLifecycle } from "./mobile-lifecycle.js";
|
|
44
|
-
export type { AppStateModule, NetInfoModule, MobileLifecycleDeps, MobileLifecycleOptions } from "./mobile-lifecycle.js";
|
|
45
|
+
export { createMobileLifecycle, createAppendLogMobileLifecycle } from "./mobile-lifecycle.js";
|
|
46
|
+
export type { AppStateModule, NetInfoModule, MobileLifecycleDeps, MobileLifecycleOptions, AppendLogLifecycleOptions } from "./mobile-lifecycle.js";
|
|
45
47
|
export { createMultiStoreSync } from "./multi-store.js";
|
|
46
48
|
export type { StoreSlice, BackupDocument, MultiStoreMigrationFn, MultiStoreSyncOptions, MultiStoreSync, } from "./multi-store.js";
|
|
47
49
|
export type { AppendOnlyClientInfo } from "./config.js";
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { configurePlatform } from "@drakkar.software/starfish-protocol";
|
|
3
|
-
import { stableStringify as
|
|
3
|
+
import { stableStringify as stableStringify2, computeHash } from "@drakkar.software/starfish-protocol";
|
|
4
4
|
import { buildRevocationList, revocationListCanonicalSigningInput } from "@drakkar.software/starfish-protocol";
|
|
5
5
|
|
|
6
6
|
// src/client.ts
|
|
7
7
|
import {
|
|
8
|
+
AUTHOR_PUBKEY_FIELD,
|
|
9
|
+
AUTHOR_SIGNATURE_FIELD,
|
|
10
|
+
DATA_FIELD,
|
|
11
|
+
TS_FIELD,
|
|
12
|
+
BASE_HASH_FIELD,
|
|
13
|
+
PUSH_PATH_PREFIX,
|
|
14
|
+
HEADER_AUTHORIZATION,
|
|
15
|
+
HEADER_SIG,
|
|
16
|
+
HEADER_TS,
|
|
17
|
+
HEADER_NONCE,
|
|
18
|
+
HEADER_ALG,
|
|
19
|
+
HEADER_PUB,
|
|
20
|
+
HEADER_CONTENT_TYPE,
|
|
21
|
+
HEADER_ACCEPT,
|
|
22
|
+
DEFAULT_ALG,
|
|
23
|
+
signAppendAuthor,
|
|
8
24
|
signRequest,
|
|
9
25
|
stableStringify
|
|
10
26
|
} from "@drakkar.software/starfish-protocol";
|
|
@@ -27,6 +43,9 @@ var StarfishHttpError = class extends Error {
|
|
|
27
43
|
|
|
28
44
|
// src/client.ts
|
|
29
45
|
var APPEND_DEFAULT_FIELD = "items";
|
|
46
|
+
function stripPushPrefix(path) {
|
|
47
|
+
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
48
|
+
}
|
|
30
49
|
function encodeCapAuth(cap) {
|
|
31
50
|
const json = stableStringify(cap);
|
|
32
51
|
if (typeof btoa === "function") {
|
|
@@ -38,6 +57,7 @@ function encodeCapAuth(cap) {
|
|
|
38
57
|
}
|
|
39
58
|
var StarfishClient = class {
|
|
40
59
|
baseUrl;
|
|
60
|
+
namespace;
|
|
41
61
|
capProvider;
|
|
42
62
|
fetch;
|
|
43
63
|
/**
|
|
@@ -47,6 +67,7 @@ var StarfishClient = class {
|
|
|
47
67
|
plugins;
|
|
48
68
|
constructor(options) {
|
|
49
69
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
70
|
+
this.namespace = options.namespace || void 0;
|
|
50
71
|
this.capProvider = options.capProvider;
|
|
51
72
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
52
73
|
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
@@ -70,6 +91,20 @@ var StarfishClient = class {
|
|
|
70
91
|
return "";
|
|
71
92
|
}
|
|
72
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Rewrite a request path for the configured namespace. A no-op when no
|
|
96
|
+
* namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
|
|
97
|
+
* (the `/v1` protocol-version segment is part of the namespaced route, matching
|
|
98
|
+
* the Python client and the server's namespace mount).
|
|
99
|
+
*
|
|
100
|
+
* Applied to the path used for BOTH the signature and the URL so the canonical
|
|
101
|
+
* path the client signs equals the path the server reconstructs from the URL.
|
|
102
|
+
* Covers SDK-helper-built paths too — that's the point: a namespace-unaware
|
|
103
|
+
* helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
|
|
104
|
+
*/
|
|
105
|
+
applyNamespace(path) {
|
|
106
|
+
return this.namespace ? `/v1/${this.namespace}${path}` : path;
|
|
107
|
+
}
|
|
73
108
|
/**
|
|
74
109
|
* Build auth headers for a request. When a `capProvider` is set, signs the
|
|
75
110
|
* request with the device's Ed25519 private key and returns the v3 header
|
|
@@ -81,28 +116,57 @@ var StarfishClient = class {
|
|
|
81
116
|
* The host bound into the signature is derived from `baseUrl` once per call.
|
|
82
117
|
*/
|
|
83
118
|
async buildAuthHeaders(method, pathAndQuery, body) {
|
|
84
|
-
if (this.capProvider) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
if (!this.capProvider) return {};
|
|
120
|
+
const capCtx = await this.capProvider.getCap();
|
|
121
|
+
return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build the request-signing headers from an ALREADY-fetched cap context. Split
|
|
125
|
+
* out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
|
|
126
|
+
* reuse it for BOTH the author signature (over the element data) and the
|
|
127
|
+
* request signature (over the body), without redeeming the cap twice — a
|
|
128
|
+
* second `getCap()` could rotate keys and break the `authorPubkey ===
|
|
129
|
+
* presenter` bind the server checks.
|
|
130
|
+
*/
|
|
131
|
+
async capRequestHeaders(capCtx, method, pathAndQuery, body) {
|
|
132
|
+
const { cap, devEdPrivHex, pubHex, presenterAlg } = capCtx;
|
|
133
|
+
const req = {
|
|
134
|
+
method,
|
|
135
|
+
pathAndQuery,
|
|
136
|
+
body,
|
|
137
|
+
host: this.signingHost()
|
|
138
|
+
};
|
|
139
|
+
const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
|
|
140
|
+
const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
|
|
141
|
+
alg: signAlg
|
|
142
|
+
});
|
|
143
|
+
const headers = {
|
|
144
|
+
[HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
|
|
145
|
+
[HEADER_SIG]: sig,
|
|
146
|
+
[HEADER_TS]: String(ts),
|
|
147
|
+
[HEADER_NONCE]: nonce,
|
|
148
|
+
[HEADER_ALG]: alg
|
|
149
|
+
};
|
|
150
|
+
if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
|
|
151
|
+
return headers;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the author public key to attach to a signed append: the redeemer's
|
|
155
|
+
* `pubHex` for an audience cap, else the cert subject `cap.sub` for a
|
|
156
|
+
* device/member cap. This is the SAME key that signs the request, so a server
|
|
157
|
+
* enforcing author proof can bind the stored element to its writer. Returns
|
|
158
|
+
* undefined only for a (malformed) cap with neither — the append then goes
|
|
159
|
+
* unsigned and a server requiring signatures rejects it.
|
|
160
|
+
*/
|
|
161
|
+
appendAuthorKey(capCtx) {
|
|
162
|
+
const { cap, pubHex, presenterAlg } = capCtx;
|
|
163
|
+
const authorPubHex = pubHex ?? cap.sub;
|
|
164
|
+
if (authorPubHex === void 0) return null;
|
|
165
|
+
const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
|
|
166
|
+
return { authorPubHex, signAlg };
|
|
103
167
|
}
|
|
104
168
|
async pull(path, checkpointOrOptions) {
|
|
105
|
-
let pathAndQuery = path;
|
|
169
|
+
let pathAndQuery = this.applyNamespace(path);
|
|
106
170
|
let appendField;
|
|
107
171
|
if (typeof checkpointOrOptions === "number") {
|
|
108
172
|
if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
@@ -134,7 +198,7 @@ var StarfishClient = class {
|
|
|
134
198
|
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
135
199
|
const res = await this.fetch(url, {
|
|
136
200
|
method: "GET",
|
|
137
|
-
headers: {
|
|
201
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
138
202
|
});
|
|
139
203
|
if (!res.ok) {
|
|
140
204
|
throw new StarfishHttpError(res.status, await res.text());
|
|
@@ -146,27 +210,62 @@ var StarfishClient = class {
|
|
|
146
210
|
}
|
|
147
211
|
return result;
|
|
148
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Pull several collections in one round-trip via `/batch/pull`. `collections`
|
|
215
|
+
* is the list of collection names; `opts.params` supplies path params per
|
|
216
|
+
* collection (serialized to a URL-encoded JSON `params` query). The server
|
|
217
|
+
* auto-fills the `{identity}` param from the authenticated caller, so per-user
|
|
218
|
+
* collections need no params. Returns a map of collection name → its pulled
|
|
219
|
+
* document or a per-collection `{ error }`. Honors the configured namespace.
|
|
220
|
+
*
|
|
221
|
+
* Note: not append/checkpoint-aware — for incremental append-only reads use
|
|
222
|
+
* `pull(path, { since })` (or `AppendLogCursor`) per collection.
|
|
223
|
+
*/
|
|
224
|
+
async batchPull(collections, opts = {}) {
|
|
225
|
+
const search = new URLSearchParams();
|
|
226
|
+
search.set("collections", collections.join(","));
|
|
227
|
+
if (opts.params && Object.keys(opts.params).length > 0) {
|
|
228
|
+
search.set("params", JSON.stringify(opts.params));
|
|
229
|
+
}
|
|
230
|
+
const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
|
|
231
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
232
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
233
|
+
const res = await this.fetch(url, {
|
|
234
|
+
method: "GET",
|
|
235
|
+
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
|
|
236
|
+
});
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
239
|
+
}
|
|
240
|
+
return await res.json();
|
|
241
|
+
}
|
|
149
242
|
/**
|
|
150
243
|
* Push synced data to the server.
|
|
151
244
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
152
245
|
* @param data - The full document data to push
|
|
153
246
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
154
247
|
*
|
|
155
|
-
* v3 author
|
|
156
|
-
*
|
|
248
|
+
* v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
|
|
249
|
+
* (produced by `SyncManager` when a `signer` is configured) and sent as
|
|
250
|
+
* top-level body siblings of `data`, where the server verifies it.
|
|
157
251
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
158
252
|
*/
|
|
159
|
-
async push(path, data, baseHash) {
|
|
253
|
+
async push(path, data, baseHash, author) {
|
|
160
254
|
const body = JSON.stringify({
|
|
161
|
-
data,
|
|
162
|
-
baseHash
|
|
255
|
+
[DATA_FIELD]: data,
|
|
256
|
+
[BASE_HASH_FIELD]: baseHash,
|
|
257
|
+
...author && {
|
|
258
|
+
[AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
|
|
259
|
+
[AUTHOR_SIGNATURE_FIELD]: author.authorSignature
|
|
260
|
+
}
|
|
163
261
|
});
|
|
164
|
-
const
|
|
165
|
-
const
|
|
262
|
+
const sendPath = this.applyNamespace(path);
|
|
263
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
|
|
264
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
166
265
|
method: "POST",
|
|
167
266
|
headers: {
|
|
168
|
-
|
|
169
|
-
|
|
267
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
268
|
+
[HEADER_ACCEPT]: "application/json",
|
|
170
269
|
...authHeaders
|
|
171
270
|
},
|
|
172
271
|
body
|
|
@@ -179,21 +278,77 @@ var StarfishClient = class {
|
|
|
179
278
|
}
|
|
180
279
|
return res.json();
|
|
181
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Append an element to an appendOnly (`by_timestamp`) collection.
|
|
283
|
+
*
|
|
284
|
+
* Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
|
|
285
|
+
* authorized append is always accepted. Each element is stored server-side as
|
|
286
|
+
* `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
|
|
287
|
+
*
|
|
288
|
+
* @param path - the push endpoint (e.g. "/push/events")
|
|
289
|
+
* @param data - the element payload. For a `delegated` collection, encrypt it
|
|
290
|
+
* first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
|
|
291
|
+
* server stores it opaquely and never reads it.
|
|
292
|
+
* @param opts.ts - optional client-supplied element timestamp (ms). Must be a
|
|
293
|
+
* non-negative integer strictly greater than the latest stored element's ts
|
|
294
|
+
* (else the server responds 409). Omit to let the server assign one.
|
|
295
|
+
* @throws {StarfishHttpError} on a non-2xx response — e.g. 409
|
|
296
|
+
* `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
|
|
297
|
+
* `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
|
|
298
|
+
* cap is reached (partition by a path parameter for higher volume).
|
|
299
|
+
*/
|
|
300
|
+
async append(path, data, opts = {}) {
|
|
301
|
+
const sendPath = this.applyNamespace(path);
|
|
302
|
+
const bodyObj = { [DATA_FIELD]: data };
|
|
303
|
+
if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
|
|
304
|
+
const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
|
|
305
|
+
if (capCtx) {
|
|
306
|
+
const authorKey = this.appendAuthorKey(capCtx);
|
|
307
|
+
if (authorKey) {
|
|
308
|
+
const documentKey = stripPushPrefix(path);
|
|
309
|
+
const { authorPubkey, authorSignature } = signAppendAuthor(
|
|
310
|
+
documentKey,
|
|
311
|
+
data,
|
|
312
|
+
authorKey.authorPubHex,
|
|
313
|
+
capCtx.devEdPrivHex,
|
|
314
|
+
authorKey.signAlg
|
|
315
|
+
);
|
|
316
|
+
bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
|
|
317
|
+
bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const body = JSON.stringify(bodyObj);
|
|
321
|
+
const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
|
|
322
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: {
|
|
325
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
326
|
+
[HEADER_ACCEPT]: "application/json",
|
|
327
|
+
...authHeaders
|
|
328
|
+
},
|
|
329
|
+
body
|
|
330
|
+
});
|
|
331
|
+
if (!res.ok) {
|
|
332
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
333
|
+
}
|
|
334
|
+
return res.json();
|
|
335
|
+
}
|
|
182
336
|
/**
|
|
183
337
|
* Pull binary data from a blob collection.
|
|
184
338
|
* Returns raw bytes with the content hash from the ETag header.
|
|
185
339
|
*/
|
|
186
340
|
async pullBlob(path) {
|
|
187
|
-
const
|
|
188
|
-
const
|
|
341
|
+
const sendPath = this.applyNamespace(path);
|
|
342
|
+
const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
|
|
343
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
189
344
|
method: "GET",
|
|
190
|
-
headers: {
|
|
345
|
+
headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
|
|
191
346
|
});
|
|
192
347
|
if (!res.ok) {
|
|
193
348
|
throw new StarfishHttpError(res.status, await res.text());
|
|
194
349
|
}
|
|
195
350
|
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
196
|
-
const contentType = res.headers.get(
|
|
351
|
+
const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
|
|
197
352
|
const data = await res.arrayBuffer();
|
|
198
353
|
return { data, hash: etag, contentType };
|
|
199
354
|
}
|
|
@@ -202,12 +357,13 @@ var StarfishClient = class {
|
|
|
202
357
|
* Binary collections use last-write-wins (no conflict detection).
|
|
203
358
|
*/
|
|
204
359
|
async pushBlob(path, data, contentType) {
|
|
205
|
-
const
|
|
206
|
-
const
|
|
360
|
+
const sendPath = this.applyNamespace(path);
|
|
361
|
+
const authHeaders = await this.buildAuthHeaders("POST", sendPath, void 0);
|
|
362
|
+
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
207
363
|
method: "POST",
|
|
208
364
|
headers: {
|
|
209
|
-
|
|
210
|
-
|
|
365
|
+
[HEADER_CONTENT_TYPE]: contentType,
|
|
366
|
+
[HEADER_ACCEPT]: "application/json",
|
|
211
367
|
...authHeaders
|
|
212
368
|
},
|
|
213
369
|
body: data
|
|
@@ -220,7 +376,13 @@ var StarfishClient = class {
|
|
|
220
376
|
};
|
|
221
377
|
|
|
222
378
|
// src/sync.ts
|
|
223
|
-
import {
|
|
379
|
+
import {
|
|
380
|
+
AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
|
|
381
|
+
AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
|
|
382
|
+
deepMerge,
|
|
383
|
+
docAuthorCanonicalInput,
|
|
384
|
+
getBase64
|
|
385
|
+
} from "@drakkar.software/starfish-protocol";
|
|
224
386
|
|
|
225
387
|
// src/validate.ts
|
|
226
388
|
var ValidationError = class extends Error {
|
|
@@ -332,23 +494,24 @@ var SyncManager = class {
|
|
|
332
494
|
try {
|
|
333
495
|
const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
334
496
|
if (this.aborted) throw new AbortError();
|
|
335
|
-
let
|
|
497
|
+
let author;
|
|
336
498
|
if (this.signer) {
|
|
337
499
|
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
338
500
|
if (this.aborted) throw new AbortError();
|
|
339
|
-
const
|
|
501
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
502
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
340
503
|
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
341
504
|
if (this.aborted) throw new AbortError();
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
authorSignature: getBase64().encode(sigBytes)
|
|
505
|
+
author = {
|
|
506
|
+
[AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
|
|
507
|
+
[AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
|
|
346
508
|
};
|
|
347
509
|
}
|
|
348
510
|
const result = await this.client.push(
|
|
349
511
|
this.pushPath,
|
|
350
|
-
|
|
351
|
-
this.lastHash
|
|
512
|
+
sealed,
|
|
513
|
+
this.lastHash,
|
|
514
|
+
author
|
|
352
515
|
);
|
|
353
516
|
if (this.aborted) throw new AbortError();
|
|
354
517
|
this.lastHash = result.hash;
|
|
@@ -390,6 +553,209 @@ var SyncManager = class {
|
|
|
390
553
|
}
|
|
391
554
|
};
|
|
392
555
|
|
|
556
|
+
// src/append-log.ts
|
|
557
|
+
import {
|
|
558
|
+
DEFAULT_ALG as DEFAULT_ALG2,
|
|
559
|
+
verifyAppendAuthor
|
|
560
|
+
} from "@drakkar.software/starfish-protocol";
|
|
561
|
+
var PULL_PATH_PREFIX = "/pull/";
|
|
562
|
+
function stripPullPrefix(path) {
|
|
563
|
+
return path.startsWith(PULL_PATH_PREFIX) ? path.slice(PULL_PATH_PREFIX.length) : path;
|
|
564
|
+
}
|
|
565
|
+
var AppendAuthorError = class extends Error {
|
|
566
|
+
constructor(ts) {
|
|
567
|
+
super(`append element author verification failed (ts=${ts})`);
|
|
568
|
+
this.ts = ts;
|
|
569
|
+
this.name = "AppendAuthorError";
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
function checkpointOf(items) {
|
|
573
|
+
let max = 0;
|
|
574
|
+
for (const it of items) if (it.ts > max) max = it.ts;
|
|
575
|
+
return max;
|
|
576
|
+
}
|
|
577
|
+
function withAuthor(ts, data, src) {
|
|
578
|
+
const out = { ts, data };
|
|
579
|
+
if (src.authorPubkey !== void 0) out.authorPubkey = src.authorPubkey;
|
|
580
|
+
if (src.authorSignature !== void 0) out.authorSignature = src.authorSignature;
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
var AppendLogCursor = class {
|
|
584
|
+
client;
|
|
585
|
+
pullPath;
|
|
586
|
+
appendField;
|
|
587
|
+
encryptor;
|
|
588
|
+
verifyAuthor;
|
|
589
|
+
onElementError;
|
|
590
|
+
persistEncrypted;
|
|
591
|
+
documentKey;
|
|
592
|
+
logger;
|
|
593
|
+
loggerName;
|
|
594
|
+
items;
|
|
595
|
+
lastCheckpoint;
|
|
596
|
+
/** Tail of the serialized pull chain. Concurrent `pull()` calls queue behind
|
|
597
|
+
* it so each runs against the checkpoint the previous one advanced — no two
|
|
598
|
+
* overlapping fetches read the same checkpoint and double-append a window. */
|
|
599
|
+
pullChain = Promise.resolve();
|
|
600
|
+
constructor(options) {
|
|
601
|
+
this.client = options.client;
|
|
602
|
+
this.pullPath = options.pullPath;
|
|
603
|
+
this.appendField = options.appendField ?? "items";
|
|
604
|
+
this.encryptor = options.encryptor;
|
|
605
|
+
this.verifyAuthor = options.verifyAuthor;
|
|
606
|
+
this.onElementError = options.onElementError ?? "throw";
|
|
607
|
+
this.persistEncrypted = options.persistEncrypted ?? false;
|
|
608
|
+
this.documentKey = stripPullPrefix(options.pullPath);
|
|
609
|
+
this.logger = options.logger;
|
|
610
|
+
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
611
|
+
const seed = options.initialItems ?? [];
|
|
612
|
+
const seedCheckpoint = checkpointOf(seed);
|
|
613
|
+
if (options.since != null) {
|
|
614
|
+
if (options.since < 0) throw new Error("since must be non-negative");
|
|
615
|
+
if (options.since < seedCheckpoint) {
|
|
616
|
+
throw new Error("since must be >= the max ts of initialItems");
|
|
617
|
+
}
|
|
618
|
+
this.lastCheckpoint = options.since;
|
|
619
|
+
} else {
|
|
620
|
+
this.lastCheckpoint = seedCheckpoint;
|
|
621
|
+
}
|
|
622
|
+
this.items = [...seed];
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Fetch elements newer than the current checkpoint, verify + decrypt them,
|
|
626
|
+
* append them to the local log, and return ONLY the newly-fetched batch
|
|
627
|
+
* (decrypted when an `encryptor` is set).
|
|
628
|
+
*
|
|
629
|
+
* Atomic under `onElementError: "throw"` (the default): the batch is fully
|
|
630
|
+
* verified and decrypted into a local before any state mutation, so a
|
|
631
|
+
* verify/decrypt failure throws without advancing the checkpoint past elements
|
|
632
|
+
* that could never be re-fetched. Under `"skip"`, a failing element is dropped
|
|
633
|
+
* from the returned batch but the checkpoint still advances past it.
|
|
634
|
+
*
|
|
635
|
+
* Safe to call concurrently: overlapping calls are serialized internally, so
|
|
636
|
+
* each runs against the checkpoint the previous one advanced (no double-fetch
|
|
637
|
+
* of the same window). The next pull after one completes will pick up anything
|
|
638
|
+
* that arrived in between.
|
|
639
|
+
*/
|
|
640
|
+
async pull() {
|
|
641
|
+
const run = this.pullChain.then(
|
|
642
|
+
() => this.doPull(),
|
|
643
|
+
() => this.doPull()
|
|
644
|
+
);
|
|
645
|
+
this.pullChain = run.then(
|
|
646
|
+
() => void 0,
|
|
647
|
+
() => void 0
|
|
648
|
+
);
|
|
649
|
+
return run;
|
|
650
|
+
}
|
|
651
|
+
async doPull() {
|
|
652
|
+
this.logger?.pullStart(this.loggerName);
|
|
653
|
+
const start = performance.now();
|
|
654
|
+
try {
|
|
655
|
+
const since = this.lastCheckpoint;
|
|
656
|
+
const opts = since > 0 ? { appendField: this.appendField, since } : { appendField: this.appendField };
|
|
657
|
+
const raw = await this.client.pull(this.pullPath, opts);
|
|
658
|
+
const batch = [];
|
|
659
|
+
const stored = [];
|
|
660
|
+
let maxTs = since;
|
|
661
|
+
let skipped = 0;
|
|
662
|
+
for (const el of raw) {
|
|
663
|
+
if (since > 0 && el.ts <= since) continue;
|
|
664
|
+
if (el.ts > maxTs) maxTs = el.ts;
|
|
665
|
+
let decrypted = null;
|
|
666
|
+
try {
|
|
667
|
+
this.verifyOne(el);
|
|
668
|
+
const data = this.encryptor ? await this.encryptor.decrypt(el.data) : el.data;
|
|
669
|
+
decrypted = withAuthor(el.ts, data, el);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (this.onElementError !== "skip") throw err;
|
|
672
|
+
skipped++;
|
|
673
|
+
}
|
|
674
|
+
if (this.persistEncrypted) {
|
|
675
|
+
stored.push(withAuthor(el.ts, el.data, el));
|
|
676
|
+
} else if (decrypted) {
|
|
677
|
+
stored.push(decrypted);
|
|
678
|
+
}
|
|
679
|
+
if (decrypted) batch.push(decrypted);
|
|
680
|
+
}
|
|
681
|
+
this.items.push(...stored);
|
|
682
|
+
this.lastCheckpoint = maxTs;
|
|
683
|
+
this.logger?.pullSuccess(
|
|
684
|
+
this.loggerName,
|
|
685
|
+
Math.round(performance.now() - start),
|
|
686
|
+
skipped > 0 ? { skippedCount: skipped } : void 0
|
|
687
|
+
);
|
|
688
|
+
return batch;
|
|
689
|
+
} catch (err) {
|
|
690
|
+
this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
691
|
+
throw err;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/** Verify a single element's author signature over its RAW (pre-decryption)
|
|
695
|
+
* `data`. Throws {@link AppendAuthorError} on any failure. No-op when
|
|
696
|
+
* verification is disabled. */
|
|
697
|
+
verifyOne(el) {
|
|
698
|
+
if (!this.verifyAuthor) return;
|
|
699
|
+
const policy = typeof this.verifyAuthor === "object" ? this.verifyAuthor : {};
|
|
700
|
+
const { authorPubkey, authorSignature } = el;
|
|
701
|
+
if (!authorPubkey || !authorSignature) throw new AppendAuthorError(el.ts);
|
|
702
|
+
if (policy.expectedAuthorPubkey && authorPubkey.toLowerCase() !== policy.expectedAuthorPubkey.toLowerCase()) {
|
|
703
|
+
throw new AppendAuthorError(el.ts);
|
|
704
|
+
}
|
|
705
|
+
const ok = verifyAppendAuthor(
|
|
706
|
+
this.documentKey,
|
|
707
|
+
el.data,
|
|
708
|
+
authorPubkey,
|
|
709
|
+
authorSignature,
|
|
710
|
+
policy.alg ?? DEFAULT_ALG2
|
|
711
|
+
);
|
|
712
|
+
if (!ok) throw new AppendAuthorError(el.ts);
|
|
713
|
+
}
|
|
714
|
+
/** The full accumulated log (a shallow copy), in `ts` order. Under
|
|
715
|
+
* `persistEncrypted` these carry CIPHERTEXT `data` (persist them as-is, then
|
|
716
|
+
* re-seed via `initialItems`); otherwise they carry decrypted/plaintext data. */
|
|
717
|
+
getItems() {
|
|
718
|
+
return [...this.items];
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The full accumulated log, DECRYPTED — for rendering warm-started history in
|
|
722
|
+
* `persistEncrypted` mode (where {@link getItems} holds ciphertext). Honors
|
|
723
|
+
* `onElementError` (a `"skip"` cursor drops elements it cannot read). When the
|
|
724
|
+
* cursor has no `encryptor`, or is not in `persistEncrypted` mode, the held
|
|
725
|
+
* elements are already plaintext/decrypted and are returned as-is.
|
|
726
|
+
*/
|
|
727
|
+
async getDecryptedItems() {
|
|
728
|
+
const snapshot = [...this.items];
|
|
729
|
+
if (!this.encryptor || !this.persistEncrypted) return snapshot;
|
|
730
|
+
const out = [];
|
|
731
|
+
for (const el of snapshot) {
|
|
732
|
+
try {
|
|
733
|
+
this.verifyOne(el);
|
|
734
|
+
const data = await this.encryptor.decrypt(el.data);
|
|
735
|
+
out.push(withAuthor(el.ts, data, el));
|
|
736
|
+
} catch (err) {
|
|
737
|
+
if (this.onElementError !== "skip") throw err;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
/** The current checkpoint: the max `ts` held (the next pull's `since`). `0`
|
|
743
|
+
* when nothing has been pulled or seeded. */
|
|
744
|
+
getCheckpoint() {
|
|
745
|
+
return this.lastCheckpoint;
|
|
746
|
+
}
|
|
747
|
+
/** Restore the checkpoint without seeding items — for persistence layers that
|
|
748
|
+
* store only the checkpoint. Used to resume incrementally across restarts.
|
|
749
|
+
* Rejects a value below the max `ts` already held: rewinding would make the
|
|
750
|
+
* next pull re-deliver, and duplicate, elements the cursor already has. */
|
|
751
|
+
setCheckpoint(ts) {
|
|
752
|
+
if (ts < checkpointOf(this.items)) {
|
|
753
|
+
throw new Error("checkpoint must be >= the max ts already held");
|
|
754
|
+
}
|
|
755
|
+
this.lastCheckpoint = ts;
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
393
759
|
// src/index.ts
|
|
394
760
|
import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
|
|
395
761
|
|
|
@@ -1175,6 +1541,29 @@ function createMobileLifecycle(store, deps, options = {}) {
|
|
|
1175
1541
|
netUnsub?.();
|
|
1176
1542
|
};
|
|
1177
1543
|
}
|
|
1544
|
+
function createAppendLogMobileLifecycle(store, deps, options = {}) {
|
|
1545
|
+
const { pullOnForeground = true } = options;
|
|
1546
|
+
const appSub = deps.appState.addEventListener("change", (appState) => {
|
|
1547
|
+
if (appState === "active" && pullOnForeground) {
|
|
1548
|
+
const { online, loading } = store.getState();
|
|
1549
|
+
if (online && !loading) {
|
|
1550
|
+
store.getState().pull().catch((err) => {
|
|
1551
|
+
console.error("[Starfish] foreground log pull failed:", err);
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
let netUnsub = null;
|
|
1557
|
+
if (deps.netInfo) {
|
|
1558
|
+
netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
|
|
1559
|
+
store.getState().setOnline(!!isConnected);
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
return () => {
|
|
1563
|
+
appSub.remove();
|
|
1564
|
+
netUnsub?.();
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1178
1567
|
|
|
1179
1568
|
// src/multi-store.ts
|
|
1180
1569
|
function createMultiStoreSync(options) {
|
|
@@ -1227,6 +1616,8 @@ function createMultiStoreSync(options) {
|
|
|
1227
1616
|
}
|
|
1228
1617
|
export {
|
|
1229
1618
|
AbortError,
|
|
1619
|
+
AppendAuthorError,
|
|
1620
|
+
AppendLogCursor,
|
|
1230
1621
|
ConflictError,
|
|
1231
1622
|
ENCRYPTED_KEY,
|
|
1232
1623
|
SnapshotHistory,
|
|
@@ -1235,10 +1626,12 @@ export {
|
|
|
1235
1626
|
SyncManager,
|
|
1236
1627
|
ValidationError,
|
|
1237
1628
|
buildRevocationList,
|
|
1629
|
+
checkpointOf,
|
|
1238
1630
|
classifyError,
|
|
1239
1631
|
computeHash,
|
|
1240
1632
|
configurePlatform,
|
|
1241
1633
|
consoleSyncLogger,
|
|
1634
|
+
createAppendLogMobileLifecycle,
|
|
1242
1635
|
createDebouncedPush,
|
|
1243
1636
|
createDebouncedSync,
|
|
1244
1637
|
createDedupFetch,
|
|
@@ -1262,7 +1655,7 @@ export {
|
|
|
1262
1655
|
registerBackgroundSync,
|
|
1263
1656
|
registerServiceWorker,
|
|
1264
1657
|
revocationListCanonicalSigningInput,
|
|
1265
|
-
|
|
1658
|
+
stableStringify2 as stableStringify,
|
|
1266
1659
|
startAdaptivePolling,
|
|
1267
1660
|
startPolling,
|
|
1268
1661
|
timestampWinner,
|