@drakkar.software/starfish-client 3.0.0-alpha.6 → 3.0.0-alpha.8

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.
@@ -230,7 +230,22 @@ import { useEffect, useRef, useState, useCallback } from "react";
230
230
 
231
231
  // src/client.ts
232
232
  import {
233
+ AUTHOR_PUBKEY_FIELD,
234
+ AUTHOR_SIGNATURE_FIELD,
235
+ DATA_FIELD,
236
+ TS_FIELD,
237
+ BASE_HASH_FIELD,
238
+ PUSH_PATH_PREFIX,
239
+ HEADER_AUTHORIZATION,
240
+ HEADER_SIG,
241
+ HEADER_TS,
242
+ HEADER_NONCE,
243
+ HEADER_ALG,
244
+ HEADER_PUB,
245
+ HEADER_CONTENT_TYPE,
246
+ HEADER_ACCEPT,
233
247
  DEFAULT_ALG,
248
+ signAppendAuthor,
234
249
  signRequest,
235
250
  stableStringify
236
251
  } from "@drakkar.software/starfish-protocol";
@@ -253,6 +268,9 @@ var StarfishHttpError = class extends Error {
253
268
 
254
269
  // src/client.ts
255
270
  var APPEND_DEFAULT_FIELD = "items";
271
+ function stripPushPrefix(path) {
272
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
273
+ }
256
274
  function encodeCapAuth(cap) {
257
275
  const json = stableStringify(cap);
258
276
  if (typeof btoa === "function") {
@@ -323,29 +341,54 @@ var StarfishClient = class {
323
341
  * The host bound into the signature is derived from `baseUrl` once per call.
324
342
  */
325
343
  async buildAuthHeaders(method, pathAndQuery, body) {
326
- if (this.capProvider) {
327
- const { cap, devEdPrivHex, pubHex, presenterAlg } = await this.capProvider.getCap();
328
- const req = {
329
- method,
330
- pathAndQuery,
331
- body,
332
- host: this.signingHost()
333
- };
334
- const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
335
- const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
336
- alg: signAlg
337
- });
338
- const headers = {
339
- Authorization: `Cap ${encodeCapAuth(cap)}`,
340
- "X-Starfish-Sig": sig,
341
- "X-Starfish-Ts": String(ts),
342
- "X-Starfish-Nonce": nonce,
343
- "X-Starfish-Alg": alg
344
- };
345
- if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
346
- return headers;
347
- }
348
- return {};
344
+ if (!this.capProvider) return {};
345
+ const capCtx = await this.capProvider.getCap();
346
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
347
+ }
348
+ /**
349
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
350
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
351
+ * reuse it for BOTH the author signature (over the element data) and the
352
+ * request signature (over the body), without redeeming the cap twice a
353
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
354
+ * presenter` bind the server checks.
355
+ */
356
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
357
+ const { cap, devEdPrivHex, pubHex, presenterAlg } = capCtx;
358
+ const req = {
359
+ method,
360
+ pathAndQuery,
361
+ body,
362
+ host: this.signingHost()
363
+ };
364
+ const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
365
+ const { alg, sig, ts, nonce } = await signRequest(req, devEdPrivHex, {
366
+ alg: signAlg
367
+ });
368
+ const headers = {
369
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
370
+ [HEADER_SIG]: sig,
371
+ [HEADER_TS]: String(ts),
372
+ [HEADER_NONCE]: nonce,
373
+ [HEADER_ALG]: alg
374
+ };
375
+ if (pubHex !== void 0) headers[HEADER_PUB] = pubHex;
376
+ return headers;
377
+ }
378
+ /**
379
+ * Resolve the author public key to attach to a signed append: the redeemer's
380
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
381
+ * device/member cap. This is the SAME key that signs the request, so a server
382
+ * enforcing author proof can bind the stored element to its writer. Returns
383
+ * undefined only for a (malformed) cap with neither — the append then goes
384
+ * unsigned and a server requiring signatures rejects it.
385
+ */
386
+ appendAuthorKey(capCtx) {
387
+ const { cap, pubHex, presenterAlg } = capCtx;
388
+ const authorPubHex = pubHex ?? cap.sub;
389
+ if (authorPubHex === void 0) return null;
390
+ const signAlg = cap.kind === "audience" ? presenterAlg ?? DEFAULT_ALG : cap.subAlg ?? cap.issAlg;
391
+ return { authorPubHex, signAlg };
349
392
  }
350
393
  async pull(path, checkpointOrOptions) {
351
394
  let pathAndQuery = this.applyNamespace(path);
@@ -380,7 +423,7 @@ var StarfishClient = class {
380
423
  const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
381
424
  const res = await this.fetch(url, {
382
425
  method: "GET",
383
- headers: { Accept: "application/json", ...authHeaders }
426
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
384
427
  });
385
428
  if (!res.ok) {
386
429
  throw new StarfishHttpError(res.status, await res.text());
@@ -398,22 +441,27 @@ var StarfishClient = class {
398
441
  * @param data - The full document data to push
399
442
  * @param baseHash - Hash of the document this push is based on (null for first push)
400
443
  *
401
- * v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
402
- * and are produced by `SyncManager` when a `signer` is configured.
444
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
445
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
446
+ * top-level body siblings of `data`, where the server verifies it.
403
447
  * @throws {ConflictError} if the server detects a hash mismatch (409)
404
448
  */
405
- async push(path, data, baseHash) {
449
+ async push(path, data, baseHash, author) {
406
450
  const body = JSON.stringify({
407
- data,
408
- baseHash
451
+ [DATA_FIELD]: data,
452
+ [BASE_HASH_FIELD]: baseHash,
453
+ ...author && {
454
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
455
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature
456
+ }
409
457
  });
410
458
  const sendPath = this.applyNamespace(path);
411
459
  const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
412
460
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
413
461
  method: "POST",
414
462
  headers: {
415
- "Content-Type": "application/json",
416
- Accept: "application/json",
463
+ [HEADER_CONTENT_TYPE]: "application/json",
464
+ [HEADER_ACCEPT]: "application/json",
417
465
  ...authHeaders
418
466
  },
419
467
  body
@@ -446,16 +494,32 @@ var StarfishClient = class {
446
494
  * cap is reached (partition by a path parameter for higher volume).
447
495
  */
448
496
  async append(path, data, opts = {}) {
449
- const bodyObj = { data };
450
- if (opts.ts !== void 0) bodyObj["ts"] = opts.ts;
451
- const body = JSON.stringify(bodyObj);
452
497
  const sendPath = this.applyNamespace(path);
453
- const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
498
+ const bodyObj = { [DATA_FIELD]: data };
499
+ if (opts.ts !== void 0) bodyObj[TS_FIELD] = opts.ts;
500
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
501
+ if (capCtx) {
502
+ const authorKey = this.appendAuthorKey(capCtx);
503
+ if (authorKey) {
504
+ const documentKey = stripPushPrefix(path);
505
+ const { authorPubkey, authorSignature } = signAppendAuthor(
506
+ documentKey,
507
+ data,
508
+ authorKey.authorPubHex,
509
+ capCtx.devEdPrivHex,
510
+ authorKey.signAlg
511
+ );
512
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
513
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
514
+ }
515
+ }
516
+ const body = JSON.stringify(bodyObj);
517
+ const authHeaders = capCtx ? await this.capRequestHeaders(capCtx, "POST", sendPath, body) : {};
454
518
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
455
519
  method: "POST",
456
520
  headers: {
457
- "Content-Type": "application/json",
458
- Accept: "application/json",
521
+ [HEADER_CONTENT_TYPE]: "application/json",
522
+ [HEADER_ACCEPT]: "application/json",
459
523
  ...authHeaders
460
524
  },
461
525
  body
@@ -474,13 +538,13 @@ var StarfishClient = class {
474
538
  const authHeaders = await this.buildAuthHeaders("GET", sendPath, void 0);
475
539
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
476
540
  method: "GET",
477
- headers: { Accept: "*/*", ...authHeaders }
541
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders }
478
542
  });
479
543
  if (!res.ok) {
480
544
  throw new StarfishHttpError(res.status, await res.text());
481
545
  }
482
546
  const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
483
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
547
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
484
548
  const data = await res.arrayBuffer();
485
549
  return { data, hash: etag, contentType };
486
550
  }
@@ -494,8 +558,8 @@ var StarfishClient = class {
494
558
  const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
495
559
  method: "POST",
496
560
  headers: {
497
- "Content-Type": contentType,
498
- Accept: "application/json",
561
+ [HEADER_CONTENT_TYPE]: contentType,
562
+ [HEADER_ACCEPT]: "application/json",
499
563
  ...authHeaders
500
564
  },
501
565
  body: data
@@ -508,7 +572,13 @@ var StarfishClient = class {
508
572
  };
509
573
 
510
574
  // src/sync.ts
511
- import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
575
+ import {
576
+ AUTHOR_PUBKEY_FIELD as AUTHOR_PUBKEY_FIELD2,
577
+ AUTHOR_SIGNATURE_FIELD as AUTHOR_SIGNATURE_FIELD2,
578
+ deepMerge,
579
+ docAuthorCanonicalInput,
580
+ getBase64
581
+ } from "@drakkar.software/starfish-protocol";
512
582
 
513
583
  // src/validate.ts
514
584
  var ValidationError = class extends Error {
@@ -613,23 +683,24 @@ var SyncManager = class {
613
683
  try {
614
684
  const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
615
685
  if (this.aborted) throw new AbortError();
616
- let payload = sealed;
686
+ let author;
617
687
  if (this.signer) {
618
688
  const { devEdPubHex, sign } = await this.signer.getSigner();
619
689
  if (this.aborted) throw new AbortError();
620
- const canonical = stableStringify2(sealed);
690
+ const documentKey = stripPushPrefix(this.pushPath);
691
+ const canonical = docAuthorCanonicalInput(documentKey, sealed);
621
692
  const sigBytes = await sign(new TextEncoder().encode(canonical));
622
693
  if (this.aborted) throw new AbortError();
623
- payload = {
624
- ...sealed,
625
- authorPubkey: devEdPubHex,
626
- authorSignature: getBase64().encode(sigBytes)
694
+ author = {
695
+ [AUTHOR_PUBKEY_FIELD2]: devEdPubHex,
696
+ [AUTHOR_SIGNATURE_FIELD2]: getBase64().encode(sigBytes)
627
697
  };
628
698
  }
629
699
  const result = await this.client.push(
630
700
  this.pushPath,
631
- payload,
632
- this.lastHash
701
+ sealed,
702
+ this.lastHash,
703
+ author
633
704
  );
634
705
  if (this.aborted) throw new AbortError();
635
706
  this.lastHash = result.hash;