@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
package/dist/client.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
2
2
  import { type AppendAuthor } from "@drakkar.software/starfish-protocol";
3
3
  import type { StarfishClientOptions } from "./types.js";
4
+ /**
5
+ * Whether a {@link PullResult} was served from the offline read-through cache
6
+ * (the transport was unreachable) rather than a live server response. Used by
7
+ * {@link SyncManager} to surface a `stale` flag to the UI without treating a
8
+ * cache hit as proof the server is reachable.
9
+ */
10
+ export declare function pullWasFromCache(result: PullResult): boolean;
4
11
  /** The storage `documentKey` for a push `path`: the path with the `/push/`
5
12
  * action prefix stripped (the namespace lives only in the URL). The author
6
13
  * signature binds to this key. */
@@ -76,12 +83,20 @@ export declare class StarfishClient {
76
83
  private readonly namespace?;
77
84
  private readonly capProvider?;
78
85
  private readonly fetch;
86
+ private readonly cache?;
87
+ private readonly cacheMaxAgeMs?;
79
88
  /**
80
89
  * Installed client-side plugins. Currently stored as inert data; no
81
90
  * hooks fire yet. Extensions can inspect this list if needed.
82
91
  */
83
92
  readonly plugins: ReadonlyArray<import("./types.js").ClientPlugin>;
84
93
  constructor(options: StarfishClientOptions);
94
+ /**
95
+ * Mark a `PullResult` as having been served from the offline read-through
96
+ * cache (transport was unreachable). Non-enumerable so it doesn't leak into
97
+ * JSON / equality / re-caching; read via {@link pullWasFromCache}.
98
+ */
99
+ private tagFromCache;
85
100
  /**
86
101
  * Resolve the host portion of the URL the client will send to. The host
87
102
  * is folded into the signed canonical input as the `h` field so the
@@ -142,6 +157,18 @@ export declare class StarfishClient {
142
157
  pull(path: string, options: PullOptions): Promise<PullResult>;
143
158
  /** Pull an append-only collection. Extracts and returns `data[appendField]` as `T[]`. */
144
159
  pull<T = unknown>(path: string, options: AppendPullOptions): Promise<T[]>;
160
+ /**
161
+ * Read the cached snapshot for a document `path` WITHOUT hitting the network —
162
+ * the basis for cache-first paint (seed the UI from the last-synced snapshot,
163
+ * then revalidate with a live {@link pull}). Returns the tagged `PullResult`,
164
+ * or null when no cache is configured / there's no entry. Namespacing matches
165
+ * {@link pull}, so the key lines up with whatever `pull` wrote.
166
+ */
167
+ peekCache(path: string): Promise<PullResult | null>;
168
+ /** Read + parse a cached pull snapshot, tagged {@link tagFromCache}. Returns
169
+ * null on a miss or an unparseable blob (never throws — a corrupt cache entry
170
+ * must not break a pull, just miss). */
171
+ private readCache;
145
172
  /**
146
173
  * Pull several documents in one round-trip via `/batch/pull`. `collections` is
147
174
  * the list of distinct collection names; `opts.params` supplies, per collection,
package/dist/client.js CHANGED
@@ -1,277 +1,60 @@
1
- import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, DATA_FIELD, TS_FIELD, BASE_HASH_FIELD, PUSH_PATH_PREFIX, HEADER_AUTHORIZATION, HEADER_SIG, HEADER_TS, HEADER_NONCE, HEADER_PUB, HEADER_CONTENT_TYPE, HEADER_ACCEPT, signAppendAuthor, signRequest, stableStringify, } from "@drakkar.software/starfish-protocol";
2
1
  import { ConflictError, StarfishHttpError } from "./types.js";
3
- const APPEND_DEFAULT_FIELD = "items";
4
- /** The storage `documentKey` for a push `path`: the path with the `/push/`
5
- * action prefix stripped (the namespace lives only in the URL). The author
6
- * signature binds to this key. */
7
- export function stripPushPrefix(path) {
8
- return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
9
- }
10
- /**
11
- * Base64-encode the canonical stable-stringification of a cap-cert.
12
- *
13
- * Used as the value of the `Authorization: Cap <…>` header in v3.0. We rely
14
- * on the host's `btoa` for browsers and fall back to `Buffer` in Node so the
15
- * client stays free of native dependencies.
16
- */
17
- function encodeCapAuth(cap) {
18
- const json = stableStringify(cap);
19
- if (typeof btoa === "function") {
20
- return btoa(json);
21
- }
22
- const bufCtor = globalThis.Buffer;
23
- if (bufCtor)
24
- return bufCtor.from(json, "utf-8").toString("base64");
25
- throw new Error("No base64 encoder available");
26
- }
27
2
  /**
28
3
  * Low-level HTTP client for the Starfish sync protocol.
29
4
  * Handles auth headers and response parsing.
30
5
  */
31
6
  export class StarfishClient {
32
7
  baseUrl;
33
- namespace;
34
- capProvider;
8
+ auth;
35
9
  fetch;
36
- /**
37
- * Installed client-side plugins. Currently stored as inert data; no
38
- * hooks fire yet. Extensions can inspect this list if needed.
39
- */
40
- plugins;
41
10
  constructor(options) {
42
11
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
43
- // Empty string ⇒ no namespace (treat like unset), so a falsy env value
44
- // doesn't produce a malformed `/v1//…` path.
45
- this.namespace = options.namespace || undefined;
46
- this.capProvider = options.capProvider;
12
+ this.auth = options.auth;
47
13
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
48
- this.plugins = options.plugins ? [...options.plugins] : [];
49
- }
50
- /**
51
- * Resolve the host portion of the URL the client will send to. The host
52
- * is folded into the signed canonical input as the `h` field so the
53
- * server can refuse a signature that was minted against a different
54
- * Starfish host (replay-across-servers defence).
55
- *
56
- * When `baseUrl` is relative — e.g. the consumer passed a custom `fetch`
57
- * that resolves relative URLs in its own context — there is no parseable
58
- * host; we return `""` so signing still proceeds. The server-side
59
- * verifier will also reconstruct host from its inbound URL, so the
60
- * empty-host case still verifies symmetrically when both sides agree.
61
- */
62
- signingHost() {
63
- try {
64
- return new URL(this.baseUrl).host;
65
- }
66
- catch {
67
- return "";
68
- }
69
- }
70
- /**
71
- * Rewrite a request path for the configured namespace. A no-op when no
72
- * namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
73
- * (the `/v1` protocol-version segment is part of the namespaced route, matching
74
- * the Python client and the server's namespace mount).
75
- *
76
- * Applied to the path used for BOTH the signature and the URL so the canonical
77
- * path the client signs equals the path the server reconstructs from the URL.
78
- * Covers SDK-helper-built paths too — that's the point: a namespace-unaware
79
- * helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
80
- */
81
- applyNamespace(path) {
82
- return this.namespace ? `/v1/${this.namespace}${path}` : path;
83
14
  }
84
15
  /**
85
- * Build auth headers for a request. When a `capProvider` is set, signs the
86
- * request with the device's Ed25519 private key and returns the v3 header
87
- * set (`Authorization: Cap …`, `X-Starfish-Sig`, `X-Starfish-Ts`,
88
- * `X-Starfish-Nonce`). Empty when no provider is configured (public reads).
89
- *
90
- * Body bytes signed MUST equal the bytes sent on the wire — callers pass
91
- * the already-serialized body string here so signing and transmission agree.
92
- * The host bound into the signature is derived from `baseUrl` once per call.
16
+ * Pull synced data from the server.
17
+ * @param path - The pull endpoint path (e.g. "/pull/users/abc/settings")
18
+ * @param checkpoint - Only return data updated after this timestamp (0 = full pull)
93
19
  */
94
- async buildAuthHeaders(method, pathAndQuery, body) {
95
- if (!this.capProvider)
96
- return {};
97
- const capCtx = await this.capProvider.getCap();
98
- return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
99
- }
100
- /**
101
- * Build the request-signing headers from an ALREADY-fetched cap context. Split
102
- * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
103
- * reuse it for BOTH the author signature (over the element data) and the
104
- * request signature (over the body), without redeeming the cap twice — a
105
- * second `getCap()` could rotate keys and break the `authorPubkey ===
106
- * presenter` bind the server checks.
107
- */
108
- async capRequestHeaders(capCtx, method, pathAndQuery, body) {
109
- const { cap, devEdPrivHex, pubHex } = capCtx;
110
- const req = {
111
- method,
112
- pathAndQuery,
113
- body,
114
- host: this.signingHost(),
115
- };
116
- const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
117
- const headers = {
118
- [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
119
- [HEADER_SIG]: sig,
120
- [HEADER_TS]: String(ts),
121
- [HEADER_NONCE]: nonce,
122
- };
123
- // Audience (public-link) caps bind no single subject, so the server needs
124
- // the presenter's pubkey to verify the signature and check the allow-list.
125
- if (pubHex !== undefined)
126
- headers[HEADER_PUB] = pubHex;
127
- return headers;
128
- }
129
- /**
130
- * Resolve the author public key to attach to a signed append: the redeemer's
131
- * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
132
- * device/member cap. This is the SAME key that signs the request, so a server
133
- * enforcing author proof can bind the stored element to its writer. Returns
134
- * undefined only for a (malformed) cap with neither — the append then goes
135
- * unsigned and a server requiring signatures rejects it.
136
- */
137
- appendAuthorKey(capCtx) {
138
- const { cap, pubHex } = capCtx;
139
- const authorPubHex = pubHex ?? cap.sub;
140
- if (authorPubHex === undefined)
141
- return null;
142
- return { authorPubHex };
143
- }
144
- async pull(path, checkpointOrOptions) {
145
- let pathAndQuery = this.applyNamespace(path);
146
- let appendField;
147
- if (typeof checkpointOrOptions === "number") {
148
- if (checkpointOrOptions)
149
- pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
150
- }
151
- else if (checkpointOrOptions != null) {
152
- // Disambiguate AppendPullOptions vs PullOptions.
153
- //
154
- // PullOptions are identified by the presence of `withKeyring` or
155
- // `checkpoint` keys (which AppendPullOptions does not have — append
156
- // uses `since`, not `checkpoint`). Anything else, including an empty
157
- // `{}` object, retains the historical behavior of AppendPullOptions
158
- // (extracts `data.items` with `?` query).
159
- const opts = checkpointOrOptions;
160
- const isPullOptions = opts.withKeyring !== undefined || opts.checkpoint !== undefined;
161
- const params = new URLSearchParams();
162
- if (isPullOptions) {
163
- if (opts.checkpoint != null && opts.checkpoint > 0) {
164
- params.set("checkpoint", String(opts.checkpoint));
165
- }
166
- if (opts.withKeyring) {
167
- params.set("withKeyring", "1");
168
- }
169
- }
170
- else {
171
- appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
172
- if (opts.since != null) {
173
- if (opts.since < 0)
174
- throw new Error("since must be non-negative");
175
- params.set("checkpoint", String(opts.since));
176
- }
177
- if (opts.last != null) {
178
- if (opts.last < 0)
179
- throw new Error("last must be non-negative");
180
- params.set("last", String(opts.last));
181
- }
182
- }
183
- if (params.size > 0)
184
- pathAndQuery += `?${params.toString()}`;
185
- }
186
- const url = `${this.baseUrl}${pathAndQuery}`;
187
- const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, undefined);
188
- const res = await this.fetch(url, {
189
- method: "GET",
190
- headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
191
- });
192
- if (!res.ok) {
193
- throw new StarfishHttpError(res.status, await res.text());
194
- }
195
- const result = await res.json();
196
- if (appendField !== undefined) {
197
- const list = result.data?.[appendField];
198
- return (Array.isArray(list) ? list : []);
199
- }
200
- return result;
201
- }
202
- /**
203
- * Pull several documents in one round-trip via `/batch/pull`. `collections` is
204
- * the list of distinct collection names; `opts.params` supplies, per collection,
205
- * an ARRAY of path-param sets — one per document to read — so the SAME collection
206
- * can fan in many documents (e.g. many users' `profile`) in a single request.
207
- * The server auto-fills the `{identity}` param from the authenticated caller for
208
- * any set that omits it, so a self-doc collection needs no params. Returns a map
209
- * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
210
- * in request order. Honors the configured namespace.
211
- *
212
- * For the common "many docs of one collection" case prefer {@link batchPullMany}.
213
- *
214
- * Note: not append/checkpoint-aware — for incremental append-only reads use
215
- * `pull(path, { since })` (or `AppendLogCursor`) per collection.
216
- */
217
- async batchPull(collections, opts = {}) {
218
- const search = new URLSearchParams();
219
- search.set("collections", collections.join(","));
220
- if (opts.params && Object.keys(opts.params).length > 0) {
221
- search.set("params", JSON.stringify(opts.params));
222
- }
223
- const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
224
- const url = `${this.baseUrl}${pathAndQuery}`;
225
- const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, undefined);
20
+ async pull(path, checkpoint) {
21
+ const url = checkpoint
22
+ ? `${this.baseUrl}${path}?checkpoint=${checkpoint}`
23
+ : `${this.baseUrl}${path}`;
24
+ const authHeaders = this.auth
25
+ ? await this.auth({ method: "GET", path, body: null })
26
+ : {};
226
27
  const res = await this.fetch(url, {
227
28
  method: "GET",
228
- headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
29
+ headers: { Accept: "application/json", ...authHeaders },
229
30
  });
230
31
  if (!res.ok) {
231
32
  throw new StarfishHttpError(res.status, await res.text());
232
33
  }
233
- return await res.json();
234
- }
235
- /**
236
- * Convenience over {@link batchPull} for reading MANY documents of ONE
237
- * collection in a single round-trip: pass the per-document param-sets and get
238
- * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
239
- * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
240
- * issues no request and returns `[]`.
241
- */
242
- async batchPullMany(collection, paramsList) {
243
- if (paramsList.length === 0)
244
- return [];
245
- const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
246
- return res.collections[collection] ?? [];
34
+ return res.json();
247
35
  }
248
36
  /**
249
37
  * Push synced data to the server.
250
38
  * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
251
39
  * @param data - The full document data to push
252
40
  * @param baseHash - Hash of the document this push is based on (null for first push)
253
- *
254
- * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
255
- * (produced by `SyncManager` when a `signer` is configured) and sent as
256
- * top-level body siblings of `data`, where the server verifies it.
41
+ * @param authorSignature - Optional author signature for provenance
257
42
  * @throws {ConflictError} if the server detects a hash mismatch (409)
258
43
  */
259
- async push(path, data, baseHash, author) {
44
+ async push(path, data, baseHash, authorSignature) {
260
45
  const body = JSON.stringify({
261
- [DATA_FIELD]: data,
262
- [BASE_HASH_FIELD]: baseHash,
263
- ...(author && {
264
- [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
265
- [AUTHOR_SIGNATURE_FIELD]: author.authorSignature,
266
- }),
46
+ data,
47
+ baseHash,
48
+ ...(authorSignature && { authorSignature }),
267
49
  });
268
- const sendPath = this.applyNamespace(path);
269
- const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
270
- const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
50
+ const authHeaders = this.auth
51
+ ? await this.auth({ method: "POST", path, body })
52
+ : {};
53
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
271
54
  method: "POST",
272
55
  headers: {
273
- [HEADER_CONTENT_TYPE]: "application/json",
274
- [HEADER_ACCEPT]: "application/json",
56
+ "Content-Type": "application/json",
57
+ Accept: "application/json",
275
58
  ...authHeaders,
276
59
  },
277
60
  body,
@@ -284,84 +67,23 @@ export class StarfishClient {
284
67
  }
285
68
  return res.json();
286
69
  }
287
- /**
288
- * Append an element to an appendOnly (`by_timestamp`) collection.
289
- *
290
- * Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
291
- * authorized append is always accepted. Each element is stored server-side as
292
- * `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
293
- *
294
- * @param path - the push endpoint (e.g. "/push/events")
295
- * @param data - the element payload. For a `delegated` collection, encrypt it
296
- * first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
297
- * server stores it opaquely and never reads it.
298
- * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
299
- * non-negative integer strictly greater than the latest stored element's ts
300
- * (else the server responds 409). Omit to let the server assign one.
301
- * @throws {StarfishHttpError} on a non-2xx response — e.g. 409
302
- * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
303
- * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
304
- * cap is reached (partition by a path parameter for higher volume).
305
- */
306
- async append(path, data, opts = {}) {
307
- const sendPath = this.applyNamespace(path);
308
- const bodyObj = { [DATA_FIELD]: data };
309
- if (opts.ts !== undefined)
310
- bodyObj[TS_FIELD] = opts.ts;
311
- // Author proof. Fetch the cap ONCE and reuse it for both the author
312
- // signature (over the element `data`) and the request signature (over the
313
- // final body) — see {@link capRequestHeaders}. The author fields are signed
314
- // with the same key that authenticates the request, so a collection with
315
- // `requireAuthorSignature` (the default) binds the stored element to its
316
- // writer. Without a cap provider the append is sent unsigned and such a
317
- // collection rejects it.
318
- const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
319
- if (capCtx) {
320
- const authorKey = this.appendAuthorKey(capCtx);
321
- if (authorKey) {
322
- // The signature binds the author to BOTH the element data AND the
323
- // document it is written to (the storage path = `path` minus the
324
- // `/push/` action prefix; the namespace lives only in the URL).
325
- const documentKey = stripPushPrefix(path);
326
- const { authorPubkey, authorSignature } = signAppendAuthor(documentKey, data, authorKey.authorPubHex, capCtx.devEdPrivHex);
327
- bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
328
- bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
329
- }
330
- }
331
- const body = JSON.stringify(bodyObj);
332
- const authHeaders = capCtx
333
- ? await this.capRequestHeaders(capCtx, "POST", sendPath, body)
334
- : {};
335
- const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
336
- method: "POST",
337
- headers: {
338
- [HEADER_CONTENT_TYPE]: "application/json",
339
- [HEADER_ACCEPT]: "application/json",
340
- ...authHeaders,
341
- },
342
- body,
343
- });
344
- if (!res.ok) {
345
- throw new StarfishHttpError(res.status, await res.text());
346
- }
347
- return res.json();
348
- }
349
70
  /**
350
71
  * Pull binary data from a blob collection.
351
72
  * Returns raw bytes with the content hash from the ETag header.
352
73
  */
353
74
  async pullBlob(path) {
354
- const sendPath = this.applyNamespace(path);
355
- const authHeaders = await this.buildAuthHeaders("GET", sendPath, undefined);
356
- const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
75
+ const authHeaders = this.auth
76
+ ? await this.auth({ method: "GET", path, body: null })
77
+ : {};
78
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
357
79
  method: "GET",
358
- headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders },
80
+ headers: { Accept: "*/*", ...authHeaders },
359
81
  });
360
82
  if (!res.ok) {
361
83
  throw new StarfishHttpError(res.status, await res.text());
362
84
  }
363
85
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
364
- const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
86
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
365
87
  const data = await res.arrayBuffer();
366
88
  return { data, hash: etag, contentType };
367
89
  }
@@ -370,15 +92,14 @@ export class StarfishClient {
370
92
  * Binary collections use last-write-wins (no conflict detection).
371
93
  */
372
94
  async pushBlob(path, data, contentType) {
373
- // Blobs are not JSON; we leave body undefined when signing — server-side
374
- // verification is expected to use a separate path for blob uploads.
375
- const sendPath = this.applyNamespace(path);
376
- const authHeaders = await this.buildAuthHeaders("POST", sendPath, undefined);
377
- const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
95
+ const authHeaders = this.auth
96
+ ? await this.auth({ method: "POST", path, body: null })
97
+ : {};
98
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
378
99
  method: "POST",
379
100
  headers: {
380
- [HEADER_CONTENT_TYPE]: contentType,
381
- [HEADER_ACCEPT]: "application/json",
101
+ "Content-Type": contentType,
102
+ Accept: "application/json",
382
103
  ...authHeaders,
383
104
  },
384
105
  body: data,
package/dist/crypto.js ADDED
@@ -0,0 +1,49 @@
1
+ import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
2
+ const ALGO = "AES-GCM";
3
+ export { ENCRYPTED_KEY };
4
+ /**
5
+ * Creates an Encryptor that uses AES-256-GCM with HKDF-derived keys.
6
+ */
7
+ export function createEncryptor(secret, salt, info = "starfish-e2e") {
8
+ if (!secret)
9
+ throw new Error("encryptionSecret must not be empty");
10
+ if (!salt)
11
+ throw new Error("encryptionSalt must not be empty");
12
+ const keyPromise = deriveKey(secret, salt, info);
13
+ return {
14
+ async encrypt(data) {
15
+ const key = await keyPromise;
16
+ const c = getCrypto();
17
+ const b64 = getBase64();
18
+ const plaintext = new TextEncoder().encode(JSON.stringify(data));
19
+ const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
20
+ const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
21
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
22
+ combined.set(iv);
23
+ combined.set(new Uint8Array(ciphertext), iv.length);
24
+ return { [ENCRYPTED_KEY]: b64.encode(combined) };
25
+ },
26
+ async decrypt(wrapper) {
27
+ const encoded = wrapper[ENCRYPTED_KEY];
28
+ if (typeof encoded !== "string") {
29
+ throw new Error("Expected encrypted data but received unencrypted document");
30
+ }
31
+ const key = await keyPromise;
32
+ const c = getCrypto();
33
+ const b64 = getBase64();
34
+ const combined = b64.decode(encoded);
35
+ if (combined.length < IV_BYTES) {
36
+ throw new Error("Encrypted data is too short");
37
+ }
38
+ const iv = combined.slice(0, IV_BYTES);
39
+ const ciphertext = combined.slice(IV_BYTES);
40
+ try {
41
+ const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
42
+ return JSON.parse(new TextDecoder().decode(plaintext));
43
+ }
44
+ catch (err) {
45
+ throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
46
+ }
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,41 @@
1
+ import { StarfishHttpError } from "./types.js";
2
+ /**
3
+ * Fetches the list of feature slugs from a user's entitlement document.
4
+ *
5
+ * Returns an empty array if the document does not exist yet or the features
6
+ * field is absent — so callers never need to handle a 404.
7
+ *
8
+ * ```ts
9
+ * import { pullEntitlements } from "@drakkar.software/starfish-client"
10
+ *
11
+ * const features = await pullEntitlements(client, userId)
12
+ * // e.g. ["premium-package-1", "paid-cloud-sync"]
13
+ *
14
+ * if (features.includes("paid-cloud-sync")) {
15
+ * // unlock cloud sync UI
16
+ * }
17
+ * ```
18
+ *
19
+ * The path template must match the server-side collection's `storagePath`.
20
+ * With the recommended default config:
21
+ * ```ts
22
+ * { storagePath: "users/{identity}/entitlements" }
23
+ * // → path: "/pull/users/{userId}/entitlements" (default)
24
+ * ```
25
+ */
26
+ export async function pullEntitlements(client, userId, opts) {
27
+ const path = (opts?.path ?? "/pull/users/{userId}/entitlements").replace("{userId}", userId);
28
+ const field = opts?.field ?? "features";
29
+ try {
30
+ const result = await client.pull(path);
31
+ const list = result.data?.[field];
32
+ if (!Array.isArray(list))
33
+ return [];
34
+ return list.filter((s) => typeof s === "string");
35
+ }
36
+ catch (err) {
37
+ if (err instanceof StarfishHttpError && err.status === 404)
38
+ return [];
39
+ throw err;
40
+ }
41
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Group encryption utilities for Starfish.
3
+ *
4
+ * Enables multiple users to share a common encrypted collection without sharing
5
+ * a passphrase. Each member holds their own credentials; a Group Encryption Key
6
+ * (GEK) is distributed per-member using X25519 ECDH key agreement.
7
+ *
8
+ * Typical flow:
9
+ * 1. Each user calls `deriveCredentials(passphrase)` — now includes groupPublicKey / groupPrivateKey.
10
+ * 2. Admin calls `createGroupKeyring(...)` to create a keyring document.
11
+ * 3. Members call `createGroupEncryptor(keyringData, myIdentity, myPrivateKey)` to get an Encryptor.
12
+ * 4. The Encryptor is passed to SyncManager via the `encryptor` option.
13
+ */
14
+ import type { Encryptor } from "./crypto.js";
15
+ /** An ECDH key pair used for group encryption. Hex-encoded for easy serialization. */
16
+ export interface GroupKeyPair {
17
+ /** Hex-encoded X25519 private key (32 bytes). Keep secret — never store on server. */
18
+ privateKey: string;
19
+ /** Hex-encoded X25519 public key (32 bytes). Safe to publish. */
20
+ publicKey: string;
21
+ }
22
+ /** One epoch's wrapped keys: each member's GEK encrypted to their public key. */
23
+ export interface EpochKeyring {
24
+ /** The admin's hex-encoded X25519 public key (used for ECDH by members). */
25
+ adminPublicKey: string;
26
+ /** Map from member identity (userId) → base64(IV || AES-GCM(GEK)) */
27
+ wrappedKeys: Record<string, string>;
28
+ }
29
+ /** The full keyring document stored in a Starfish collection. Push this with any SyncManager. */
30
+ export interface GroupKeyring {
31
+ /** The epoch number currently used for new encryptions. */
32
+ currentEpoch: number;
33
+ /** All epochs. Members unwrap the GEK for whichever epoch a document was encrypted with. */
34
+ epochs: Record<string, EpochKeyring>;
35
+ }
36
+ /**
37
+ * Derives a deterministic X25519 key pair from a passphrase + userId.
38
+ *
39
+ * The derivation uses SHA-256 with a fixed domain separator so it is distinct
40
+ * from the auth token and encryption key derivations. Same passphrase + userId
41
+ * always produces the same key pair on any device (stateless).
42
+ */
43
+ export declare function deriveGroupKeyPair(passphrase: string, userId: string): Promise<GroupKeyPair>;
44
+ /** Generates a random 256-bit Group Encryption Key as a hex string. */
45
+ export declare function generateGroupKey(): string;
46
+ /**
47
+ * Wraps a GEK for a specific member using ECDH key agreement.
48
+ *
49
+ * The wrapper (admin) and member each have an X25519 key pair. ECDH between
50
+ * `wrapperPrivateKey` and `memberPublicKey` produces a shared secret, which is
51
+ * used to derive an AES-256-GCM key that encrypts the GEK.
52
+ *
53
+ * @returns base64(IV || AES-GCM-ciphertext)
54
+ */
55
+ export declare function wrapGroupKey(gek: string, memberPublicKey: string, wrapperPrivateKey: string): Promise<string>;
56
+ /**
57
+ * Unwraps a GEK using the member's own private key and the admin's public key.
58
+ *
59
+ * ECDH between `memberPrivateKey` and `adminPublicKey` yields the same shared
60
+ * secret as the wrapping step, so the same AES key is derived and the GEK is
61
+ * recovered.
62
+ *
63
+ * @returns GEK as a hex string
64
+ */
65
+ export declare function unwrapGroupKey(wrapped: string, memberPrivateKey: string, adminPublicKey: string): Promise<string>;
66
+ /**
67
+ * Creates a new group keyring document with epoch 1.
68
+ *
69
+ * @param adminKeyPair The admin's key pair (from `deriveGroupKeyPair` or `deriveCredentials`)
70
+ * @param members Map from member identity (userId) → hex public key
71
+ * @param gek Optional GEK to use; generated randomly if omitted
72
+ * @returns The keyring document and the raw GEK (admin keeps the GEK to add future members)
73
+ */
74
+ export declare function createGroupKeyring(adminKeyPair: GroupKeyPair, members: Record<string, string>, gek?: string): Promise<{
75
+ keyring: GroupKeyring;
76
+ gek: string;
77
+ }>;
78
+ /**
79
+ * Adds a new member to the current epoch of an existing keyring.
80
+ *
81
+ * The admin supplies the current GEK (returned by `createGroupKeyring` or
82
+ * `rotateGroupKey`) and their key pair to wrap it for the new member.
83
+ * This does NOT rotate the GEK — the new member can read all existing
84
+ * documents encrypted with the current epoch key.
85
+ *
86
+ * Only the admin (whose `publicKey` matches `epochKeyring.adminPublicKey`) can
87
+ * add members, because all wrapped entries must use the same ECDH key pair.
88
+ */
89
+ export declare function addGroupMember(keyring: GroupKeyring, adminKeyPair: GroupKeyPair, currentGek: string, newMemberId: string, newMemberPublicKey: string): Promise<GroupKeyring>;
90
+ /**
91
+ * Rotates the group key, creating a new epoch.
92
+ *
93
+ * Used when removing a member. The removed member retains their old epoch key
94
+ * (and can still read old documents), but cannot read new documents.
95
+ *
96
+ * @param remainingMembers Map from identity → hex public key for members who keep access
97
+ */
98
+ export declare function rotateGroupKey(keyring: GroupKeyring, adminKeyPair: GroupKeyPair, remainingMembers: Record<string, string>, newGek?: string): Promise<{
99
+ keyring: GroupKeyring;
100
+ gek: string;
101
+ }>;
102
+ /**
103
+ * Creates an Encryptor that can decrypt any epoch and encrypts with the current epoch.
104
+ *
105
+ * Wire format: `{ _encrypted: "base64(IV || ciphertext)", _epoch: N }`
106
+ *
107
+ * @param keyring The keyring document fetched from Starfish
108
+ * @param myIdentity The caller's userId (to locate their wrapped key in each epoch)
109
+ * @param myPrivateKey The caller's hex-encoded X25519 private key
110
+ */
111
+ export declare function createGroupEncryptor(keyring: GroupKeyring, myIdentity: string, myPrivateKey: string): Promise<Encryptor>;