@drakkar.software/starfish-client 2.3.0 → 3.0.0-alpha.0

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 (62) hide show
  1. package/README.md +219 -0
  2. package/dist/_crypto_helpers.d.ts +4 -0
  3. package/dist/bindings/zustand.d.ts +5 -4
  4. package/dist/bindings/zustand.js +125 -79
  5. package/dist/bindings/zustand.js.map +4 -4
  6. package/dist/cap-mint.d.ts +20 -0
  7. package/dist/cap-mint.js +12 -0
  8. package/dist/cap-mint.js.map +7 -0
  9. package/dist/client.d.ts +52 -3
  10. package/dist/config.d.ts +1 -4
  11. package/dist/directory.d.ts +9 -0
  12. package/dist/directory.js +24 -0
  13. package/dist/directory.js.map +7 -0
  14. package/dist/identity.d.ts +4 -82
  15. package/dist/identity.js +2 -354
  16. package/dist/identity.js.map +4 -4
  17. package/dist/index.d.ts +8 -10
  18. package/dist/index.js +131 -251
  19. package/dist/index.js.map +4 -4
  20. package/dist/keyring.d.ts +6 -0
  21. package/dist/keyring.js +26 -0
  22. package/dist/keyring.js.map +7 -0
  23. package/dist/pairing.d.ts +6 -0
  24. package/dist/pairing.js +26 -0
  25. package/dist/pairing.js.map +7 -0
  26. package/dist/recipients.d.ts +6 -0
  27. package/dist/recipients.js +16 -0
  28. package/dist/recipients.js.map +7 -0
  29. package/dist/sync.d.ts +32 -8
  30. package/dist/testing.d.ts +1 -1
  31. package/dist/testing.js +2 -2
  32. package/dist/testing.js.map +2 -2
  33. package/dist/types.d.ts +48 -9
  34. package/package.json +3 -12
  35. package/dist/background-sync.js +0 -29
  36. package/dist/bindings/suspense.js +0 -49
  37. package/dist/client.js +0 -112
  38. package/dist/config.js +0 -18
  39. package/dist/crypto.js +0 -49
  40. package/dist/debounced-sync.js +0 -120
  41. package/dist/dedup.js +0 -35
  42. package/dist/entitlements.js +0 -41
  43. package/dist/export.js +0 -115
  44. package/dist/group-crypto.d.ts +0 -111
  45. package/dist/group-crypto.js +0 -205
  46. package/dist/group-crypto.js.map +0 -7
  47. package/dist/hash.d.ts +0 -10
  48. package/dist/hash.js +0 -34
  49. package/dist/history.js +0 -61
  50. package/dist/logger.js +0 -80
  51. package/dist/migrate.js +0 -38
  52. package/dist/mobile-lifecycle.js +0 -55
  53. package/dist/multi-store.js +0 -92
  54. package/dist/platform.d.ts +0 -52
  55. package/dist/platform.js +0 -62
  56. package/dist/polling.js +0 -52
  57. package/dist/resolvers.js +0 -223
  58. package/dist/service-worker.js +0 -55
  59. package/dist/storage/indexeddb.js +0 -59
  60. package/dist/sync.js +0 -127
  61. package/dist/types.js +0 -18
  62. package/dist/validate.js +0 -28
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Phase-2 transitional shim. The implementation lives in
3
+ * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.
4
+ */
5
+ export { KEYRING_WRAP_SALT, KEYRING_WRAP_INFO, KEYRING_IV_BYTES, wrapForRecipient, unwrapFromEntry, verifyEntrySignature, createKeyring, addRecipient, rotateEpoch, createKeyringEncryptor, } from "@drakkar.software/starfish-keyring";
6
+ export type { WrappedKeyEntry, KeyringEpoch, Keyring, KeyringEncryptor, } from "@drakkar.software/starfish-keyring";
@@ -0,0 +1,26 @@
1
+ // src/keyring.ts
2
+ import {
3
+ KEYRING_WRAP_SALT,
4
+ KEYRING_WRAP_INFO,
5
+ KEYRING_IV_BYTES,
6
+ wrapForRecipient,
7
+ unwrapFromEntry,
8
+ verifyEntrySignature,
9
+ createKeyring,
10
+ addRecipient,
11
+ rotateEpoch,
12
+ createKeyringEncryptor
13
+ } from "@drakkar.software/starfish-keyring";
14
+ export {
15
+ KEYRING_IV_BYTES,
16
+ KEYRING_WRAP_INFO,
17
+ KEYRING_WRAP_SALT,
18
+ addRecipient,
19
+ createKeyring,
20
+ createKeyringEncryptor,
21
+ rotateEpoch,
22
+ unwrapFromEntry,
23
+ verifyEntrySignature,
24
+ wrapForRecipient
25
+ };
26
+ //# sourceMappingURL=keyring.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/keyring.ts"],
4
+ "sourcesContent": ["/**\n * Phase-2 transitional shim. The implementation lives in\n * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.\n */\nexport {\n KEYRING_WRAP_SALT,\n KEYRING_WRAP_INFO,\n KEYRING_IV_BYTES,\n wrapForRecipient,\n unwrapFromEntry,\n verifyEntrySignature,\n createKeyring,\n addRecipient,\n rotateEpoch,\n createKeyringEncryptor,\n} from \"@drakkar.software/starfish-keyring\"\nexport type {\n WrappedKeyEntry,\n KeyringEpoch,\n Keyring,\n KeyringEncryptor,\n} from \"@drakkar.software/starfish-keyring\"\n"],
5
+ "mappings": ";AAIA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
6
+ "names": []
7
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Phase-2 transitional shim. The implementation lives in
3
+ * `@drakkar.software/starfish-identities`. Removed in Phase 3.
4
+ */
5
+ export { bootstrapRootIdentity, buildPairingQr, parsePairingQr, assemblePairingBundle, installPairingBundle, deriveCodeKey, buildPairingRequest, readPairingRequest, buildPairingResponse, readPairingResponse, } from "@drakkar.software/starfish-identities";
6
+ export type { DeviceCredentials, PairingQrPayload, PairingBundle, WrappedCekEntry, InstalledPairingResult, AssemblePairingBundleOpts, PairingRequestEncrypted, PairingResponseEncrypted, } from "@drakkar.software/starfish-identities";
@@ -0,0 +1,26 @@
1
+ // src/pairing.ts
2
+ import {
3
+ bootstrapRootIdentity,
4
+ buildPairingQr,
5
+ parsePairingQr,
6
+ assemblePairingBundle,
7
+ installPairingBundle,
8
+ deriveCodeKey,
9
+ buildPairingRequest,
10
+ readPairingRequest,
11
+ buildPairingResponse,
12
+ readPairingResponse
13
+ } from "@drakkar.software/starfish-identities";
14
+ export {
15
+ assemblePairingBundle,
16
+ bootstrapRootIdentity,
17
+ buildPairingQr,
18
+ buildPairingRequest,
19
+ buildPairingResponse,
20
+ deriveCodeKey,
21
+ installPairingBundle,
22
+ parsePairingQr,
23
+ readPairingRequest,
24
+ readPairingResponse
25
+ };
26
+ //# sourceMappingURL=pairing.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/pairing.ts"],
4
+ "sourcesContent": ["/**\n * Phase-2 transitional shim. The implementation lives in\n * `@drakkar.software/starfish-identities`. Removed in Phase 3.\n */\nexport {\n bootstrapRootIdentity,\n buildPairingQr,\n parsePairingQr,\n assemblePairingBundle,\n installPairingBundle,\n deriveCodeKey,\n buildPairingRequest,\n readPairingRequest,\n buildPairingResponse,\n readPairingResponse,\n} from \"@drakkar.software/starfish-identities\"\nexport type {\n DeviceCredentials,\n PairingQrPayload,\n PairingBundle,\n WrappedCekEntry,\n InstalledPairingResult,\n AssemblePairingBundleOpts,\n PairingRequestEncrypted,\n PairingResponseEncrypted,\n} from \"@drakkar.software/starfish-identities\"\n"],
5
+ "mappings": ";AAIA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
6
+ "names": []
7
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Phase-2 transitional shim. The implementation lives in
3
+ * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.
4
+ */
5
+ export { keyringPathFor, addCollectionRecipient, removeRecipient, listRecipients, currentEpoch, } from "@drakkar.software/starfish-keyring";
6
+ export type { RecipientRef, AdderKeys, ListedRecipient, } from "@drakkar.software/starfish-keyring";
@@ -0,0 +1,16 @@
1
+ // src/recipients.ts
2
+ import {
3
+ keyringPathFor,
4
+ addCollectionRecipient,
5
+ removeRecipient,
6
+ listRecipients,
7
+ currentEpoch
8
+ } from "@drakkar.software/starfish-keyring";
9
+ export {
10
+ addCollectionRecipient,
11
+ currentEpoch,
12
+ keyringPathFor,
13
+ listRecipients,
14
+ removeRecipient
15
+ };
16
+ //# sourceMappingURL=recipients.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/recipients.ts"],
4
+ "sourcesContent": ["/**\n * Phase-2 transitional shim. The implementation lives in\n * `@drakkar.software/starfish-keyring`. This file is removed in Phase 3.\n */\nexport {\n keyringPathFor,\n addCollectionRecipient,\n removeRecipient,\n listRecipients,\n currentEpoch,\n} from \"@drakkar.software/starfish-keyring\"\nexport type {\n RecipientRef,\n AdderKeys,\n ListedRecipient,\n} from \"@drakkar.software/starfish-keyring\"\n"],
5
+ "mappings": ";AAIA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
6
+ "names": []
7
+ }
package/dist/sync.d.ts CHANGED
@@ -1,12 +1,34 @@
1
1
  import type { PullResult } from "@drakkar.software/starfish-protocol";
2
2
  import type { ConflictResolver } from "./types.js";
3
+ import type { Encryptor } from "@drakkar.software/starfish-protocol";
3
4
  import { StarfishClient } from "./client.js";
4
- import type { Encryptor } from "./crypto.js";
5
5
  import type { SyncLogger } from "./logger.js";
6
6
  import type { Validator } from "./validate.js";
7
7
  export declare class AbortError extends Error {
8
8
  constructor();
9
9
  }
10
+ /**
11
+ * v3.0 author-signature plumbing for `SyncManager`.
12
+ *
13
+ * Returns the device's Ed25519 public key (hex) and a function that signs
14
+ * arbitrary payload bytes. `SyncManager` calls `getSigner()` once per push
15
+ * and uses the returned `sign` to produce a base64-encoded signature over
16
+ * the canonical stringification of the encrypted payload (sans author fields).
17
+ *
18
+ * Implementations typically wrap the same Ed25519 private key used by
19
+ * `StarfishCapProvider` so that `cap.sub === devEdPubHex`.
20
+ */
21
+ export interface SyncSigner {
22
+ /**
23
+ * Returns the device's `cap.sub` (Ed25519 pubkey, hex) and a payload signer.
24
+ * The `sign` function receives the canonical signing input bytes and must
25
+ * return the raw 64-byte Ed25519 signature.
26
+ */
27
+ getSigner(): Promise<{
28
+ devEdPubHex: string;
29
+ sign(payload: Uint8Array): Promise<Uint8Array>;
30
+ }>;
31
+ }
10
32
  export interface SyncManagerOptions {
11
33
  client: StarfishClient;
12
34
  pullPath: string;
@@ -15,15 +37,17 @@ export interface SyncManagerOptions {
15
37
  onConflict?: ConflictResolver;
16
38
  /** Max conflict retry attempts (default: 3). */
17
39
  maxRetries?: number;
18
- encryptionSecret?: string;
19
- encryptionSalt?: string;
20
- encryptionInfo?: string;
21
40
  /**
22
- * Pre-created Encryptor. Use this with `createGroupEncryptor` for group encryption.
23
- * Takes precedence over `encryptionSecret` / `encryptionSalt` if both are provided.
41
+ * Encryptor for client-side E2E encryption. For v3 `delegated` collections,
42
+ * build it via `createKeyringEncryptor(keyring, deviceKemKeys)`.
24
43
  */
25
44
  encryptor?: Encryptor;
26
- signData?: (data: string) => Promise<string>;
45
+ /**
46
+ * v3 author-signature plumbing. When set, every push attaches
47
+ * `authorPubkey` (= `cap.sub`) and `authorSignature` (= base64 Ed25519 over
48
+ * stable-stringify of the encrypted payload minus author fields).
49
+ */
50
+ signer?: SyncSigner;
27
51
  /** Structured logger for sync events. */
28
52
  logger?: SyncLogger;
29
53
  /** Name passed to logger methods (default: derived from pullPath). */
@@ -38,7 +62,7 @@ export declare class SyncManager {
38
62
  private readonly onConflict;
39
63
  private readonly maxRetries;
40
64
  private readonly encryptor;
41
- private readonly signData?;
65
+ private readonly signer?;
42
66
  private readonly logger?;
43
67
  private readonly loggerName;
44
68
  private readonly validate?;
package/dist/testing.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
2
2
  import { StarfishClient } from "./client.js";
3
3
  type PullFn = (path: string, checkpoint?: number) => Promise<PullResult>;
4
- type PushFn = (path: string, data: Record<string, unknown>, baseHash: string | null, sig?: string) => Promise<PushSuccess>;
4
+ type PushFn = (path: string, data: Record<string, unknown>, baseHash: string | null) => Promise<PushSuccess>;
5
5
  /**
6
6
  * Creates a mock StarfishClient for testing.
7
7
  * Override individual methods or use the defaults (returns static data).
package/dist/testing.js CHANGED
@@ -16,9 +16,9 @@ function createMockClient(overrides) {
16
16
  pullCalls.push({ path, checkpoint });
17
17
  return pull(path, checkpoint);
18
18
  },
19
- push: async (path, data, baseHash, sig) => {
19
+ push: async (path, data, baseHash) => {
20
20
  pushCalls.push({ path, data, baseHash });
21
- return push(path, data, baseHash, sig);
21
+ return push(path, data, baseHash);
22
22
  },
23
23
  pullCalls,
24
24
  pushCalls
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/testing.ts"],
4
- "sourcesContent": ["import type { PullResult, PushSuccess } from \"@drakkar.software/starfish-protocol\"\nimport { StarfishClient } from \"./client.js\"\n\ntype PullFn = (path: string, checkpoint?: number) => Promise<PullResult>\ntype PushFn = (path: string, data: Record<string, unknown>, baseHash: string | null, sig?: string) => Promise<PushSuccess>\n\n/**\n * Creates a mock StarfishClient for testing.\n * Override individual methods or use the defaults (returns static data).\n *\n * @example\n * ```ts\n * const client = createMockClient({\n * pull: async () => ({ data: { key: \"value\" }, hash: \"h1\", timestamp: 100 }),\n * })\n * const sync = new SyncManager({ client, pullPath: \"/pull/test\", pushPath: \"/push/test\" })\n * ```\n */\nexport function createMockClient(overrides?: {\n pull?: PullFn\n push?: PushFn\n}): StarfishClient & { pullCalls: Array<{ path: string; checkpoint?: number }>; pushCalls: Array<{ path: string; data: Record<string, unknown>; baseHash: string | null }> } {\n const pullCalls: Array<{ path: string; checkpoint?: number }> = []\n const pushCalls: Array<{ path: string; data: Record<string, unknown>; baseHash: string | null }> = []\n\n const pull: PullFn = overrides?.pull ?? (async () => ({\n data: {},\n hash: \"mock-hash\",\n timestamp: Date.now(),\n }))\n\n const push: PushFn = overrides?.push ?? (async () => ({\n hash: \"mock-push-hash\",\n timestamp: Date.now(),\n }))\n\n return {\n pull: async (path: string, checkpoint?: number) => {\n pullCalls.push({ path, checkpoint })\n return pull(path, checkpoint)\n },\n push: async (path: string, data: Record<string, unknown>, baseHash: string | null, sig?: string) => {\n pushCalls.push({ path, data, baseHash })\n return push(path, data, baseHash, sig)\n },\n pullCalls,\n pushCalls,\n } as unknown as StarfishClient & { pullCalls: typeof pullCalls; pushCalls: typeof pushCalls }\n}\n\n/**\n * Creates a mock fetch that returns predefined responses.\n * Useful for testing StarfishClient directly.\n *\n * @example\n * ```ts\n * const fetch = createMockFetch({ data: { key: \"value\" }, hash: \"h1\", timestamp: 100 })\n * const client = new StarfishClient({ baseUrl: \"https://example.com\", fetch })\n * ```\n */\nexport function createMockFetch(\n pullResponse?: PullResult,\n pushResponse?: PushSuccess,\n): typeof globalThis.fetch {\n return async (input) => {\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (input as Request).url\n if (url.includes(\"/pull/\")) {\n return new Response(JSON.stringify(pullResponse ?? { data: {}, hash: \"h\", timestamp: 1 }), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n return new Response(JSON.stringify(pushResponse ?? { hash: \"h\", timestamp: 1 }), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n}\n\n/**\n * Creates a mock fetch that simulates a conflict (409) on the first push,\n * then succeeds on retry. Useful for testing conflict resolution.\n */\nexport function createConflictFetch(\n conflictPullResponse: PullResult,\n successPushResponse?: PushSuccess,\n): typeof globalThis.fetch {\n let pushCount = 0\n return async (input, init?) => {\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (input as Request).url\n if (url.includes(\"/pull/\")) {\n return new Response(JSON.stringify(conflictPullResponse), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n pushCount++\n if (pushCount === 1) {\n return new Response(JSON.stringify({ error: \"hash_mismatch\" }), { status: 409 })\n }\n return new Response(JSON.stringify(successPushResponse ?? { hash: \"resolved\", timestamp: Date.now() }), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n}\n"],
5
- "mappings": ";AAkBO,SAAS,iBAAiB,WAG4I;AAC3K,QAAM,YAA0D,CAAC;AACjE,QAAM,YAA6F,CAAC;AAEpG,QAAM,OAAe,WAAW,SAAS,aAAa;AAAA,IACpD,MAAM,CAAC;AAAA,IACP,MAAM;AAAA,IACN,WAAW,KAAK,IAAI;AAAA,EACtB;AAEA,QAAM,OAAe,WAAW,SAAS,aAAa;AAAA,IACpD,MAAM;AAAA,IACN,WAAW,KAAK,IAAI;AAAA,EACtB;AAEA,SAAO;AAAA,IACL,MAAM,OAAO,MAAc,eAAwB;AACjD,gBAAU,KAAK,EAAE,MAAM,WAAW,CAAC;AACnC,aAAO,KAAK,MAAM,UAAU;AAAA,IAC9B;AAAA,IACA,MAAM,OAAO,MAAc,MAA+B,UAAyB,QAAiB;AAClG,gBAAU,KAAK,EAAE,MAAM,MAAM,SAAS,CAAC;AACvC,aAAO,KAAK,MAAM,MAAM,UAAU,GAAG;AAAA,IACvC;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAYO,SAAS,gBACd,cACA,cACyB;AACzB,SAAO,OAAO,UAAU;AACtB,UAAM,MAAM,OAAO,UAAU,WAAW,QAAQ,iBAAiB,MAAM,MAAM,OAAQ,MAAkB;AACvG,QAAI,IAAI,SAAS,QAAQ,GAAG;AAC1B,aAAO,IAAI,SAAS,KAAK,UAAU,gBAAgB,EAAE,MAAM,CAAC,GAAG,MAAM,KAAK,WAAW,EAAE,CAAC,GAAG;AAAA,QACzF,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAChD,CAAC;AAAA,IACH;AACA,WAAO,IAAI,SAAS,KAAK,UAAU,gBAAgB,EAAE,MAAM,KAAK,WAAW,EAAE,CAAC,GAAG;AAAA,MAC/E,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,IAChD,CAAC;AAAA,EACH;AACF;AAMO,SAAS,oBACd,sBACA,qBACyB;AACzB,MAAI,YAAY;AAChB,SAAO,OAAO,OAAO,SAAU;AAC7B,UAAM,MAAM,OAAO,UAAU,WAAW,QAAQ,iBAAiB,MAAM,MAAM,OAAQ,MAAkB;AACvG,QAAI,IAAI,SAAS,QAAQ,GAAG;AAC1B,aAAO,IAAI,SAAS,KAAK,UAAU,oBAAoB,GAAG;AAAA,QACxD,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAChD,CAAC;AAAA,IACH;AACA;AACA,QAAI,cAAc,GAAG;AACnB,aAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,gBAAgB,CAAC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACjF;AACA,WAAO,IAAI,SAAS,KAAK,UAAU,uBAAuB,EAAE,MAAM,YAAY,WAAW,KAAK,IAAI,EAAE,CAAC,GAAG;AAAA,MACtG,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,IAChD,CAAC;AAAA,EACH;AACF;",
4
+ "sourcesContent": ["import type { PullResult, PushSuccess } from \"@drakkar.software/starfish-protocol\"\nimport { StarfishClient } from \"./client.js\"\n\ntype PullFn = (path: string, checkpoint?: number) => Promise<PullResult>\ntype PushFn = (path: string, data: Record<string, unknown>, baseHash: string | null) => Promise<PushSuccess>\n\n/**\n * Creates a mock StarfishClient for testing.\n * Override individual methods or use the defaults (returns static data).\n *\n * @example\n * ```ts\n * const client = createMockClient({\n * pull: async () => ({ data: { key: \"value\" }, hash: \"h1\", timestamp: 100 }),\n * })\n * const sync = new SyncManager({ client, pullPath: \"/pull/test\", pushPath: \"/push/test\" })\n * ```\n */\nexport function createMockClient(overrides?: {\n pull?: PullFn\n push?: PushFn\n}): StarfishClient & { pullCalls: Array<{ path: string; checkpoint?: number }>; pushCalls: Array<{ path: string; data: Record<string, unknown>; baseHash: string | null }> } {\n const pullCalls: Array<{ path: string; checkpoint?: number }> = []\n const pushCalls: Array<{ path: string; data: Record<string, unknown>; baseHash: string | null }> = []\n\n const pull: PullFn = overrides?.pull ?? (async () => ({\n data: {},\n hash: \"mock-hash\",\n timestamp: Date.now(),\n }))\n\n const push: PushFn = overrides?.push ?? (async () => ({\n hash: \"mock-push-hash\",\n timestamp: Date.now(),\n }))\n\n return {\n pull: async (path: string, checkpoint?: number) => {\n pullCalls.push({ path, checkpoint })\n return pull(path, checkpoint)\n },\n push: async (path: string, data: Record<string, unknown>, baseHash: string | null) => {\n pushCalls.push({ path, data, baseHash })\n return push(path, data, baseHash)\n },\n pullCalls,\n pushCalls,\n } as unknown as StarfishClient & { pullCalls: typeof pullCalls; pushCalls: typeof pushCalls }\n}\n\n/**\n * Creates a mock fetch that returns predefined responses.\n * Useful for testing StarfishClient directly.\n *\n * @example\n * ```ts\n * const fetch = createMockFetch({ data: { key: \"value\" }, hash: \"h1\", timestamp: 100 })\n * const client = new StarfishClient({ baseUrl: \"https://example.com\", fetch })\n * ```\n */\nexport function createMockFetch(\n pullResponse?: PullResult,\n pushResponse?: PushSuccess,\n): typeof globalThis.fetch {\n return async (input) => {\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (input as Request).url\n if (url.includes(\"/pull/\")) {\n return new Response(JSON.stringify(pullResponse ?? { data: {}, hash: \"h\", timestamp: 1 }), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n return new Response(JSON.stringify(pushResponse ?? { hash: \"h\", timestamp: 1 }), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n}\n\n/**\n * Creates a mock fetch that simulates a conflict (409) on the first push,\n * then succeeds on retry. Useful for testing conflict resolution.\n */\nexport function createConflictFetch(\n conflictPullResponse: PullResult,\n successPushResponse?: PushSuccess,\n): typeof globalThis.fetch {\n let pushCount = 0\n return async (input, init?) => {\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (input as Request).url\n if (url.includes(\"/pull/\")) {\n return new Response(JSON.stringify(conflictPullResponse), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n pushCount++\n if (pushCount === 1) {\n return new Response(JSON.stringify({ error: \"hash_mismatch\" }), { status: 409 })\n }\n return new Response(JSON.stringify(successPushResponse ?? { hash: \"resolved\", timestamp: Date.now() }), {\n status: 200,\n headers: { \"Content-Type\": \"application/json\" },\n })\n }\n}\n"],
5
+ "mappings": ";AAkBO,SAAS,iBAAiB,WAG4I;AAC3K,QAAM,YAA0D,CAAC;AACjE,QAAM,YAA6F,CAAC;AAEpG,QAAM,OAAe,WAAW,SAAS,aAAa;AAAA,IACpD,MAAM,CAAC;AAAA,IACP,MAAM;AAAA,IACN,WAAW,KAAK,IAAI;AAAA,EACtB;AAEA,QAAM,OAAe,WAAW,SAAS,aAAa;AAAA,IACpD,MAAM;AAAA,IACN,WAAW,KAAK,IAAI;AAAA,EACtB;AAEA,SAAO;AAAA,IACL,MAAM,OAAO,MAAc,eAAwB;AACjD,gBAAU,KAAK,EAAE,MAAM,WAAW,CAAC;AACnC,aAAO,KAAK,MAAM,UAAU;AAAA,IAC9B;AAAA,IACA,MAAM,OAAO,MAAc,MAA+B,aAA4B;AACpF,gBAAU,KAAK,EAAE,MAAM,MAAM,SAAS,CAAC;AACvC,aAAO,KAAK,MAAM,MAAM,QAAQ;AAAA,IAClC;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAYO,SAAS,gBACd,cACA,cACyB;AACzB,SAAO,OAAO,UAAU;AACtB,UAAM,MAAM,OAAO,UAAU,WAAW,QAAQ,iBAAiB,MAAM,MAAM,OAAQ,MAAkB;AACvG,QAAI,IAAI,SAAS,QAAQ,GAAG;AAC1B,aAAO,IAAI,SAAS,KAAK,UAAU,gBAAgB,EAAE,MAAM,CAAC,GAAG,MAAM,KAAK,WAAW,EAAE,CAAC,GAAG;AAAA,QACzF,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAChD,CAAC;AAAA,IACH;AACA,WAAO,IAAI,SAAS,KAAK,UAAU,gBAAgB,EAAE,MAAM,KAAK,WAAW,EAAE,CAAC,GAAG;AAAA,MAC/E,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,IAChD,CAAC;AAAA,EACH;AACF;AAMO,SAAS,oBACd,sBACA,qBACyB;AACzB,MAAI,YAAY;AAChB,SAAO,OAAO,OAAO,SAAU;AAC7B,UAAM,MAAM,OAAO,UAAU,WAAW,QAAQ,iBAAiB,MAAM,MAAM,OAAQ,MAAkB;AACvG,QAAI,IAAI,SAAS,QAAQ,GAAG;AAC1B,aAAO,IAAI,SAAS,KAAK,UAAU,oBAAoB,GAAG;AAAA,QACxD,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAChD,CAAC;AAAA,IACH;AACA;AACA,QAAI,cAAc,GAAG;AACnB,aAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,gBAAgB,CAAC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACjF;AACA,WAAO,IAAI,SAAS,KAAK,UAAU,uBAAuB,EAAE,MAAM,YAAY,WAAW,KAAK,IAAI,EAAE,CAAC,GAAG;AAAA,MACtG,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,IAChD,CAAC;AAAA,EACH;AACF;",
6
6
  "names": []
7
7
  }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CapCert } from "@drakkar.software/starfish-protocol";
1
2
  /** Push conflict error (HTTP 409). */
2
3
  export declare class ConflictError extends Error {
3
4
  constructor();
@@ -9,22 +10,60 @@ export declare class StarfishHttpError extends Error {
9
10
  constructor(status: number, body: string);
10
11
  }
11
12
  /**
12
- * Auth provider: returns headers to include in requests.
13
- * Called for every authenticated request (pull and push).
13
+ * v3.0 cap-cert provider for `StarfishClient`. Returns the device's cap-cert and
14
+ * the matching Ed25519 private key (hex). The client calls `getCap()` once per
15
+ * outgoing request; implementations are expected to cache so this is cheap.
16
+ *
17
+ * When set, the client signs every outgoing request: each call carries
18
+ * `Authorization: Cap <base64(stableStringify(cap))>` plus `X-Starfish-Sig`,
19
+ * `X-Starfish-Ts`, `X-Starfish-Nonce`.
14
20
  */
15
- export type AuthProvider = (req: {
16
- method: string;
17
- path: string;
18
- body: string | null;
19
- }) => Record<string, string> | Promise<Record<string, string>>;
21
+ export interface StarfishCapProvider {
22
+ /**
23
+ * Returns the device's cap-cert and its Ed25519 private key (hex).
24
+ * Implementations are expected to cache; the client may call this once per
25
+ * authenticated request.
26
+ */
27
+ getCap(): Promise<{
28
+ cap: CapCert;
29
+ devEdPrivHex: string;
30
+ }>;
31
+ }
20
32
  /** Options for creating a StarfishClient. */
21
33
  export interface StarfishClientOptions {
22
34
  /** Base URL of the Starfish server (e.g. "https://api.example.com/v1"). */
23
35
  baseUrl: string;
24
- /** Auth provider that returns headers for authenticated requests. Optional for public-read collections. */
25
- auth?: AuthProvider;
36
+ /**
37
+ * Cap-cert provider. When set, requests are signed with Ed25519 and carry
38
+ * `Authorization: Cap <…>`. Omit for unauthenticated public-read collections.
39
+ */
40
+ capProvider?: StarfishCapProvider;
26
41
  /** Optional fetch implementation (defaults to global fetch). */
27
42
  fetch?: typeof fetch;
43
+ /**
44
+ * Optional list of client-side plugins. The list is stored on the client
45
+ * instance but does not fire any hooks yet — the contract is plumbed so
46
+ * extension packages (`starfish-identities`, `starfish-keyring`,
47
+ * `starfish-sharing`, …) can register against it later without a breaking
48
+ * API change.
49
+ *
50
+ * The current set of hooks is purposely empty; extensions that need to
51
+ * react to mint events or transport actions today can wrap the client
52
+ * directly. Future hook additions will be additive.
53
+ */
54
+ plugins?: ClientPlugin[];
55
+ }
56
+ /**
57
+ * Client-side plugin contract.
58
+ *
59
+ * A placeholder shape: the interface intentionally has no required hooks
60
+ * yet; extensions declare a plugin object with `name` and opt into
61
+ * specific lifecycle hooks once those exist. Apps wire plugins via
62
+ * `new StarfishClient({ plugins: [...] })`.
63
+ */
64
+ export interface ClientPlugin {
65
+ /** Human-readable name. Used in error messages and audit output. */
66
+ name: string;
28
67
  }
29
68
  /** Conflict resolver: given local and remote data, return merged result. */
30
69
  export type ConflictResolver = (local: Record<string, unknown>, remote: Record<string, unknown>) => Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakkar.software/starfish-client",
3
- "version": "2.3.0",
3
+ "version": "3.0.0-alpha.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Drakkar-Software/starfish.git",
@@ -37,14 +37,6 @@
37
37
  "./testing": {
38
38
  "types": "./dist/testing.d.ts",
39
39
  "import": "./dist/testing.js"
40
- },
41
- "./identity": {
42
- "types": "./dist/identity.d.ts",
43
- "import": "./dist/identity.js"
44
- },
45
- "./group": {
46
- "types": "./dist/group-crypto.d.ts",
47
- "import": "./dist/group-crypto.js"
48
40
  }
49
41
  },
50
42
  "peerDependencies": {
@@ -68,20 +60,19 @@
68
60
  }
69
61
  },
70
62
  "dependencies": {
71
- "@noble/curves": "^2.2.0",
72
- "@drakkar.software/starfish-protocol": "2.3.0"
63
+ "@drakkar.software/starfish-protocol": "3.0.0-alpha.0"
73
64
  },
74
65
  "devDependencies": {
75
66
  "@legendapp/state": "^2.0.0",
76
67
  "@testing-library/react": "^16.3.2",
77
68
  "@types/react": "^19.2.14",
78
69
  "@types/react-dom": "^19.2.3",
70
+ "esbuild": "^0.27.4",
79
71
  "hono": "^4.12.7",
80
72
  "immer": "^11.1.4",
81
73
  "jsdom": "^29.0.1",
82
74
  "react": "^19.2.4",
83
75
  "react-dom": "^19.2.4",
84
- "esbuild": "^0.27.4",
85
76
  "typescript": "^5.5.0",
86
77
  "vitest": "^3.0.0",
87
78
  "zustand": "^5.0.11"
@@ -1,29 +0,0 @@
1
- /**
2
- * Background Sync API integration for pending changes.
3
- * Uses the Web Background Sync API to retry failed sync operations
4
- * when connectivity is restored, even if the app is closed.
5
- */
6
- /** Check if the Background Sync API is supported in the current environment. */
7
- export function isBackgroundSyncSupported() {
8
- return (typeof navigator !== "undefined" &&
9
- "serviceWorker" in navigator &&
10
- "SyncManager" in globalThis);
11
- }
12
- /**
13
- * Register a background sync event with the active service worker.
14
- * Returns true if registration succeeded, false if not supported or no active SW.
15
- */
16
- export async function registerBackgroundSync(opts) {
17
- if (!isBackgroundSyncSupported())
18
- return false;
19
- const tag = opts?.tag ?? "starfish-sync";
20
- try {
21
- const registration = await navigator.serviceWorker.ready;
22
- // @ts-expect-error - SyncManager types may not be available
23
- await registration.sync.register(tag);
24
- return true;
25
- }
26
- catch {
27
- return false;
28
- }
29
- }
@@ -1,49 +0,0 @@
1
- /**
2
- * React Suspense integration for Starfish sync data.
3
- * Creates resources that throw Promises while loading (Suspense protocol).
4
- */
5
- /**
6
- * Create a Suspense-compatible resource from an async fetcher.
7
- * The first call to `read()` triggers the fetch. While loading, `read()` throws
8
- * a Promise (which React Suspense catches to show a fallback). Once resolved,
9
- * `read()` returns the value synchronously.
10
- *
11
- * @example
12
- * ```tsx
13
- * const resource = createSuspenseResource(() => syncManager.pull())
14
- * function MyComponent() {
15
- * const data = resource.read() // throws while loading, returns data when ready
16
- * return <div>{JSON.stringify(data)}</div>
17
- * }
18
- * ```
19
- */
20
- export function createSuspenseResource(fetcher) {
21
- let status = "pending";
22
- let result;
23
- let error;
24
- let promise = null;
25
- function init() {
26
- if (promise)
27
- return promise;
28
- promise = fetcher().then((value) => {
29
- status = "resolved";
30
- result = value;
31
- }, (err) => {
32
- status = "rejected";
33
- error = err;
34
- });
35
- return promise;
36
- }
37
- return {
38
- read() {
39
- switch (status) {
40
- case "pending":
41
- throw init();
42
- case "resolved":
43
- return result;
44
- case "rejected":
45
- throw error;
46
- }
47
- },
48
- };
49
- }
package/dist/client.js DELETED
@@ -1,112 +0,0 @@
1
- import { ConflictError, StarfishHttpError } from "./types.js";
2
- /**
3
- * Low-level HTTP client for the Starfish sync protocol.
4
- * Handles auth headers and response parsing.
5
- */
6
- export class StarfishClient {
7
- baseUrl;
8
- auth;
9
- fetch;
10
- constructor(options) {
11
- this.baseUrl = options.baseUrl.replace(/\/$/, "");
12
- this.auth = options.auth;
13
- this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
14
- }
15
- /**
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)
19
- */
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
- : {};
27
- const res = await this.fetch(url, {
28
- method: "GET",
29
- headers: { Accept: "application/json", ...authHeaders },
30
- });
31
- if (!res.ok) {
32
- throw new StarfishHttpError(res.status, await res.text());
33
- }
34
- return res.json();
35
- }
36
- /**
37
- * Push synced data to the server.
38
- * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
39
- * @param data - The full document data to push
40
- * @param baseHash - Hash of the document this push is based on (null for first push)
41
- * @param authorSignature - Optional author signature for provenance
42
- * @throws {ConflictError} if the server detects a hash mismatch (409)
43
- */
44
- async push(path, data, baseHash, authorSignature) {
45
- const body = JSON.stringify({
46
- data,
47
- baseHash,
48
- ...(authorSignature && { authorSignature }),
49
- });
50
- const authHeaders = this.auth
51
- ? await this.auth({ method: "POST", path, body })
52
- : {};
53
- const res = await this.fetch(`${this.baseUrl}${path}`, {
54
- method: "POST",
55
- headers: {
56
- "Content-Type": "application/json",
57
- Accept: "application/json",
58
- ...authHeaders,
59
- },
60
- body,
61
- });
62
- if (res.status === 409) {
63
- throw new ConflictError();
64
- }
65
- if (!res.ok) {
66
- throw new StarfishHttpError(res.status, await res.text());
67
- }
68
- return res.json();
69
- }
70
- /**
71
- * Pull binary data from a blob collection.
72
- * Returns raw bytes with the content hash from the ETag header.
73
- */
74
- async pullBlob(path) {
75
- const authHeaders = this.auth
76
- ? await this.auth({ method: "GET", path, body: null })
77
- : {};
78
- const res = await this.fetch(`${this.baseUrl}${path}`, {
79
- method: "GET",
80
- headers: { Accept: "*/*", ...authHeaders },
81
- });
82
- if (!res.ok) {
83
- throw new StarfishHttpError(res.status, await res.text());
84
- }
85
- const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
86
- const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
87
- const data = await res.arrayBuffer();
88
- return { data, hash: etag, contentType };
89
- }
90
- /**
91
- * Push binary data to a blob collection.
92
- * Binary collections use last-write-wins (no conflict detection).
93
- */
94
- async pushBlob(path, data, contentType) {
95
- const authHeaders = this.auth
96
- ? await this.auth({ method: "POST", path, body: null })
97
- : {};
98
- const res = await this.fetch(`${this.baseUrl}${path}`, {
99
- method: "POST",
100
- headers: {
101
- "Content-Type": contentType,
102
- Accept: "application/json",
103
- ...authHeaders,
104
- },
105
- body: data,
106
- });
107
- if (!res.ok) {
108
- throw new StarfishHttpError(res.status, await res.text());
109
- }
110
- return res.json();
111
- }
112
- }
package/dist/config.js DELETED
@@ -1,18 +0,0 @@
1
- /**
2
- * Fetch the server's collection manifest from GET /config.
3
- *
4
- * @param baseUrl - Base URL of the Starfish server (e.g. `"https://api.example.com/v1"`).
5
- * @param options.headers - Optional request headers (e.g. `Authorization`).
6
- * @throws {Error} if the server returns a non-2xx response.
7
- */
8
- export async function fetchServerConfig(baseUrl, options) {
9
- const url = `${baseUrl.replace(/\/$/, "")}/config`;
10
- const res = await fetch(url, {
11
- method: "GET",
12
- headers: options?.headers,
13
- });
14
- if (!res.ok) {
15
- throw new Error(`fetchServerConfig: ${res.status} ${res.statusText}`);
16
- }
17
- return res.json();
18
- }
package/dist/crypto.js DELETED
@@ -1,49 +0,0 @@
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
- }