@drakkar.software/starfish-client 2.3.0 → 3.0.0-alpha.1
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 +219 -0
- package/dist/_crypto_helpers.d.ts +4 -0
- package/dist/bindings/zustand.d.ts +5 -4
- package/dist/bindings/zustand.js +127 -79
- package/dist/bindings/zustand.js.map +4 -4
- package/dist/cap-mint.d.ts +20 -0
- package/dist/cap-mint.js +12 -0
- package/dist/cap-mint.js.map +7 -0
- package/dist/client.d.ts +52 -3
- package/dist/config.d.ts +1 -4
- package/dist/directory.d.ts +9 -0
- package/dist/directory.js +24 -0
- package/dist/directory.js.map +7 -0
- package/dist/identity.d.ts +4 -82
- package/dist/identity.js +2 -354
- package/dist/identity.js.map +4 -4
- package/dist/index.d.ts +8 -10
- package/dist/index.js +133 -251
- package/dist/index.js.map +4 -4
- package/dist/keyring.d.ts +6 -0
- package/dist/keyring.js +26 -0
- package/dist/keyring.js.map +7 -0
- package/dist/pairing.d.ts +6 -0
- package/dist/pairing.js +26 -0
- package/dist/pairing.js.map +7 -0
- package/dist/recipients.d.ts +6 -0
- package/dist/recipients.js +16 -0
- package/dist/recipients.js.map +7 -0
- package/dist/sync.d.ts +32 -8
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +2 -2
- package/dist/types.d.ts +55 -9
- package/package.json +3 -12
- package/dist/background-sync.js +0 -29
- package/dist/bindings/suspense.js +0 -49
- package/dist/client.js +0 -112
- package/dist/config.js +0 -18
- package/dist/crypto.js +0 -49
- package/dist/debounced-sync.js +0 -120
- package/dist/dedup.js +0 -35
- package/dist/entitlements.js +0 -41
- package/dist/export.js +0 -115
- package/dist/group-crypto.d.ts +0 -111
- package/dist/group-crypto.js +0 -205
- package/dist/group-crypto.js.map +0 -7
- package/dist/hash.d.ts +0 -10
- package/dist/hash.js +0 -34
- package/dist/history.js +0 -61
- package/dist/logger.js +0 -80
- package/dist/migrate.js +0 -38
- package/dist/mobile-lifecycle.js +0 -55
- package/dist/multi-store.js +0 -92
- package/dist/platform.d.ts +0 -52
- package/dist/platform.js +0 -62
- package/dist/polling.js +0 -52
- package/dist/resolvers.js +0 -223
- package/dist/service-worker.js +0 -55
- package/dist/storage/indexeddb.js +0 -59
- package/dist/sync.js +0 -127
- package/dist/types.js +0 -18
- package/dist/validate.js +0 -28
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# @drakkar.software/starfish-client
|
|
2
|
+
|
|
3
|
+
TypeScript client SDK for [Starfish](../../../README.md) — browser, Node.js, and React Native. Pull/push documents with hash-based conflict detection, end-to-end multi-recipient encryption, and cap-cert authorization.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @drakkar.software/starfish-client @drakkar.software/starfish-protocol
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Optional state bindings: `npm install zustand` (or `@legendapp/state`).
|
|
12
|
+
|
|
13
|
+
## What's in v3.0
|
|
14
|
+
|
|
15
|
+
Starfish 3.0 is a clean break from 2.x. The v2 `deriveCredentials` / `generatePassphrase` / Bearer-token `authProvider` / `signData` / `signatureVerifier` / `createEncryptor` / `group-crypto` surface is **deleted**. See [docs/migration/v2-to-v3.md](../../../docs/migration/v2-to-v3.md).
|
|
16
|
+
|
|
17
|
+
The v3 model in one sentence: a passphrase derives an Ed25519+X25519 **root identity**, which signs **capability certificates** for each device or member, and each authenticated request is itself Ed25519-signed under the cap's subject key.
|
|
18
|
+
|
|
19
|
+
## Quickstart (v3)
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
StarfishClient,
|
|
24
|
+
SyncManager,
|
|
25
|
+
bootstrapRootIdentity,
|
|
26
|
+
createKeyringEncryptor,
|
|
27
|
+
type Keyring,
|
|
28
|
+
} from "@drakkar.software/starfish-client"
|
|
29
|
+
|
|
30
|
+
// 1. Derive root identity + self-signed cap-cert from a passphrase.
|
|
31
|
+
const creds = await bootstrapRootIdentity(passphrase)
|
|
32
|
+
// creds = { rootEdPub, userId, device: {edPriv,edPub,kemPriv,kemPub}, capCert }
|
|
33
|
+
|
|
34
|
+
// 2. Wire StarfishClient to a CapProvider — every request is signed.
|
|
35
|
+
const client = new StarfishClient({
|
|
36
|
+
baseUrl: "https://api.example.com/v1",
|
|
37
|
+
capProvider: {
|
|
38
|
+
getCap: async () => ({ cap: creds.capCert, devEdPrivHex: creds.device.edPriv }),
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// 3. (Delegated only) build an encryptor from the collection's keyring.
|
|
43
|
+
const keyring = (await client.pull(`/pull/notes/_keyring`)).data as Keyring
|
|
44
|
+
const encryptor = await createKeyringEncryptor(
|
|
45
|
+
keyring,
|
|
46
|
+
{ kemPubHex: creds.device.kemPub, kemPrivHex: creds.device.kemPriv },
|
|
47
|
+
{ trustedAdders: [creds.rootEdPub] }, // required — pubkey(s) you trust to grant access
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// 4. Sync with optional per-push author signature.
|
|
51
|
+
const sync = new SyncManager({
|
|
52
|
+
client,
|
|
53
|
+
pullPath: `/pull/notes/${creds.userId}`,
|
|
54
|
+
pushPath: `/push/notes/${creds.userId}`,
|
|
55
|
+
encryptor,
|
|
56
|
+
signer: {
|
|
57
|
+
getSigner: async () => ({
|
|
58
|
+
devEdPubHex: creds.device.edPub,
|
|
59
|
+
sign: async (bytes) => ed25519Sign(creds.device.edPriv, bytes),
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
await sync.push({ items: ["note 1"] }) // sealed, signed, hash-checked
|
|
65
|
+
await sync.pull() // decrypted plaintext
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Identity & key derivation
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { bootstrapRootIdentity, deriveRootIdentity } from "@drakkar.software/starfish-client"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `deriveRootIdentity(passphrase)` — passphrase → `{ userId, keys: {edPriv, edPub, kemPriv, kemPub} }`. Pure derivation, no cap-cert.
|
|
75
|
+
- `bootstrapRootIdentity(passphrase)` — same derivation plus a self-signed `kind: "device"` full-scope cap-cert. Use this on the first device.
|
|
76
|
+
|
|
77
|
+
`userId = sha256(rootEdPub)[0:32]`. Two independent HKDF derivations (signing vs KEM) give full domain separation.
|
|
78
|
+
|
|
79
|
+
Details: [docs/ts/client/11-identity-key-derivation.md](../../../docs/ts/client/11-identity-key-derivation.md).
|
|
80
|
+
|
|
81
|
+
## Cap-cert minting
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { mintDeviceCap, mintMemberCap, scopes } from "@drakkar.software/starfish-client"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- `mintDeviceCap(rootEdPriv, rootEdPub, subject, scope, opts?)` — subject acts as a proxy for the issuer (`auth.identity = issUserId`). Used for additional devices the user controls.
|
|
88
|
+
- `mintMemberCap(rootEdPriv, rootEdPub, subject, scope, opts?)` — subject keeps its own identity (`auth.identity = subUserId`); cap adds collection-scoped roles only.
|
|
89
|
+
|
|
90
|
+
### Scope presets
|
|
91
|
+
|
|
92
|
+
| Preset | Ops | Paths |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `scopes.readOnly(c)` | `read`, `list` | `c/*` |
|
|
95
|
+
| `scopes.writer(c)` | `read`, `list`, `write` | `c/*`, `!c/_keyring` (cannot grant new recipients) |
|
|
96
|
+
| `scopes.admin(c)` | `read`, `list`, `write` | `c/*` (can grant via the keyring) |
|
|
97
|
+
| `scopes.rootAll()` | all | `*` (device caps only) |
|
|
98
|
+
|
|
99
|
+
The `!` prefix in `paths` is a denylist; explicit deny beats wildcard allow.
|
|
100
|
+
|
|
101
|
+
Details: [docs/ts/client/25-capability-certs.md](../../../docs/ts/client/25-capability-certs.md).
|
|
102
|
+
|
|
103
|
+
## Pairing additional devices
|
|
104
|
+
|
|
105
|
+
Three onboarding flows, all returning the same `DeviceCredentials` shape:
|
|
106
|
+
|
|
107
|
+
| Flow | Network | Helper |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| Bootstrap (first device) | none | `bootstrapRootIdentity(passphrase)` |
|
|
110
|
+
| QR (in-person, server-free) | none | `buildPairingQr` → `parsePairingQr` → `assemblePairingBundle` → `installPairingBundle` |
|
|
111
|
+
| Server-relay (remote, 6-digit code) | 2 TTL'd Starfish documents | `buildPairingRequest` → `readPairingRequest` → `buildPairingResponse` → `readPairingResponse` → `installPairingBundle` |
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import {
|
|
115
|
+
bootstrapRootIdentity,
|
|
116
|
+
buildPairingQr,
|
|
117
|
+
parsePairingQr,
|
|
118
|
+
assemblePairingBundle,
|
|
119
|
+
installPairingBundle,
|
|
120
|
+
buildPairingRequest,
|
|
121
|
+
readPairingRequest,
|
|
122
|
+
buildPairingResponse,
|
|
123
|
+
readPairingResponse,
|
|
124
|
+
deriveCodeKey,
|
|
125
|
+
} from "@drakkar.software/starfish-client"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`deriveCodeKey(code, salt, iterations?)` is the PBKDF2-HMAC-SHA256 (200 000 iterations by default) used by the relay flow.
|
|
129
|
+
|
|
130
|
+
Full walkthroughs: [docs/ts/client/24-pairing.md](../../../docs/ts/client/24-pairing.md).
|
|
131
|
+
|
|
132
|
+
## Multi-recipient delegated encryption
|
|
133
|
+
|
|
134
|
+
A `"delegated"` collection has data documents (opaque ciphertext) plus one **keyring document** at `<collection>/_keyring` that wraps the current Content Encryption Key (CEK) for each recipient via X25519 ECDH + HKDF + AES-256-GCM (HPKE-DHKEM style).
|
|
135
|
+
|
|
136
|
+
### Low-level keyring API
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import {
|
|
140
|
+
createKeyring,
|
|
141
|
+
addRecipient, // low-level: append entry to an in-memory keyring
|
|
142
|
+
rotateEpoch,
|
|
143
|
+
wrapForRecipient,
|
|
144
|
+
unwrapFromEntry,
|
|
145
|
+
verifyEntrySignature,
|
|
146
|
+
createKeyringEncryptor,
|
|
147
|
+
type Keyring,
|
|
148
|
+
type KeyringEpoch,
|
|
149
|
+
type WrappedKeyEntry,
|
|
150
|
+
type KeyringEncryptor,
|
|
151
|
+
KEYRING_WRAP_SALT,
|
|
152
|
+
KEYRING_WRAP_INFO,
|
|
153
|
+
KEYRING_IV_BYTES,
|
|
154
|
+
} from "@drakkar.software/starfish-client"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `createKeyring(adder, recipients, cek?, addedAt?)` — first-time setup, generates a CEK if not provided.
|
|
158
|
+
- `addRecipient(keyring, adder, currentCek, recipientKemHex, addedAt?)` — appends one wrap entry to the current epoch.
|
|
159
|
+
- `rotateEpoch(keyring, adder, retainedRecipients, addedAt?)` — mints a fresh CEK in `currentEpoch + 1`, re-wraps for the retained set.
|
|
160
|
+
- `createKeyringEncryptor(keyring, deviceKem, { trustedAdders, minEpoch? })` — returns an `Encryptor` compatible with `SyncManager`. Encrypts under `currentEpoch`; decrypts any epoch the device has a wrap for. `trustedAdders` is **required** (throws without it); optional `minEpoch` rejects a rolled-back keyring below the last-seen epoch.
|
|
161
|
+
|
|
162
|
+
### Collection-scoped recipient management
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import {
|
|
166
|
+
addCollectionRecipient, // adds + pushes the keyring back to the server
|
|
167
|
+
removeRecipient,
|
|
168
|
+
listRecipients,
|
|
169
|
+
currentEpoch,
|
|
170
|
+
keyringPathFor,
|
|
171
|
+
type RecipientRef,
|
|
172
|
+
type AdderKeys,
|
|
173
|
+
type ListedRecipient,
|
|
174
|
+
} from "@drakkar.software/starfish-client"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
These wrap the low-level helpers with HTTP I/O via `StarfishClient`: each operation pulls the keyring, mutates it, and pushes back with hash-checked optimistic concurrency. `addCollectionRecipient`, `removeRecipient`, and `listRecipients` all **require** a `trustedAdders` pin (they throw without one); `listRecipients` returns only provenance-verified entries.
|
|
178
|
+
|
|
179
|
+
Details + algorithm + threat model: [docs/ts/client/23-multi-recipient-delegated.md](../../../docs/ts/client/23-multi-recipient-delegated.md).
|
|
180
|
+
|
|
181
|
+
## `StarfishClient`
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
new StarfishClient({
|
|
185
|
+
baseUrl: "https://api.example.com/v1",
|
|
186
|
+
capProvider, // v3 — signs every request. Replaces v2 `auth`/`authProvider`.
|
|
187
|
+
fetch, // optional custom fetch
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`StarfishCapProvider` is a single-method protocol: `getCap(): Promise<{ cap: CapCert, devEdPrivHex: string }>`. Implementations are expected to cache. When a `capProvider` is set, every outgoing request carries `Authorization: Cap <base64(stableStringify(cap))>` plus `X-Starfish-Sig`, `X-Starfish-Ts`, `X-Starfish-Nonce`.
|
|
192
|
+
|
|
193
|
+
Omit `capProvider` for unauthenticated public reads.
|
|
194
|
+
|
|
195
|
+
## `SyncManager`
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
new SyncManager({
|
|
199
|
+
client, pullPath, pushPath,
|
|
200
|
+
encryptor, // typically createKeyringEncryptor(...)
|
|
201
|
+
signer, // SyncSigner — replaces v2 `signData`
|
|
202
|
+
onConflict, maxRetries, validate, logger,
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- `signer.getSigner()` returns `{ devEdPubHex, sign(payload) }`. When set, every push attaches `authorPubkey = cap.sub` and `authorSignature = base64(Ed25519(payload))` over the encrypted payload (without the author fields).
|
|
207
|
+
- `encryptor` is the only encryption option — the v2 single-secret `encryptionSecret`/`encryptionSalt` shorthand was removed in v3.
|
|
208
|
+
|
|
209
|
+
## Other utilities
|
|
210
|
+
|
|
211
|
+
The package also re-exports the v2 ergonomics that survived intact: `consoleSyncLogger`, `noopSyncLogger`, `createMetricsCollector`, `createMigrator`, `createSchemaValidator`, `classifyError`, conflict resolvers (`createUnionMerge`, `createSoftDeleteResolver`, `timestampWinner`, `pruneTombstones`), `SnapshotHistory`, `startPolling`/`startAdaptivePolling`, `createDedupFetch`, `fetchServerConfig`, `pullEntitlements`, `createIndexedDBStorage`, `exportData`/`importData`, `createDebouncedSync`/`createDebouncedPush`, `createMultiStoreSync`, `createMobileLifecycle`, and the Zustand/Legend bindings via the `./zustand` and `./legend` subpaths.
|
|
212
|
+
|
|
213
|
+
See the root [README.md](../../../README.md) for the catalog and [docs/ts/client/](../../../docs/ts/client/) for in-depth guides.
|
|
214
|
+
|
|
215
|
+
## Removed in v3.0
|
|
216
|
+
|
|
217
|
+
`deriveCredentials`, `generatePassphrase`, `buildInviteUrl`, `parseInviteUrl` (the v2 passphrase identity surface), `createEncryptor` and the `SyncManager` `encryptionSecret`/`encryptionSalt`/`encryptionInfo` options, `wrapGroupKey`, `createGroupKeyring`, `addGroupMember`, `rotateGroupKey`, `createGroupEncryptor`, the v2 `auth`/`authProvider` Bearer hook, the `signData` callback, and the `signatureVerifier` server hook are all gone. Code that imports any of them will fail to build against v3.
|
|
218
|
+
|
|
219
|
+
Migration runbook: [docs/migration/v2-to-v3.md](../../../docs/migration/v2-to-v3.md).
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, lengthBytes?: number): Promise<Uint8Array>;
|
|
2
|
+
export declare function bytesToHex(bytes: Uint8Array): string;
|
|
3
|
+
export declare function hexToBytes(hex: string): Uint8Array;
|
|
4
|
+
export declare function concat(...parts: Uint8Array[]): Uint8Array;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type StoreApi } from "zustand/vanilla";
|
|
2
2
|
import { type StateStorage } from "zustand/middleware";
|
|
3
3
|
import type { DevtoolsOptions } from "zustand/middleware";
|
|
4
|
+
import type { Encryptor } from "@drakkar.software/starfish-protocol";
|
|
4
5
|
import { SyncManager } from "../sync.js";
|
|
5
|
-
import type {
|
|
6
|
+
import type { StarfishCapProvider, ConflictResolver } from "../types.js";
|
|
6
7
|
import type { SyncLogger } from "../logger.js";
|
|
7
8
|
import type { Validator } from "../validate.js";
|
|
8
9
|
export interface StarfishState {
|
|
@@ -103,11 +104,11 @@ export declare function useConnectivity(store: StoreApi<StarfishStore>): void;
|
|
|
103
104
|
export declare function useLastSynced(store: StoreApi<StarfishStore>): string;
|
|
104
105
|
export interface SyncInitConfig {
|
|
105
106
|
serverUrl: string;
|
|
106
|
-
|
|
107
|
+
capProvider?: StarfishCapProvider;
|
|
107
108
|
pullPath: string;
|
|
108
109
|
pushPath: string;
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
/** Pre-built encryptor for E2E collections (build via `createKeyringEncryptor`). */
|
|
111
|
+
encryptor?: Encryptor;
|
|
111
112
|
onConflict?: ConflictResolver;
|
|
112
113
|
/** Called when pulled data arrives. Use to restore domain stores. */
|
|
113
114
|
onData?: (data: Record<string, unknown>) => void;
|
package/dist/bindings/zustand.js
CHANGED
|
@@ -228,6 +228,12 @@ var persist = persistImpl;
|
|
|
228
228
|
// src/bindings/zustand.ts
|
|
229
229
|
import { useEffect, useRef, useState, useCallback } from "react";
|
|
230
230
|
|
|
231
|
+
// src/client.ts
|
|
232
|
+
import {
|
|
233
|
+
signRequest,
|
|
234
|
+
stableStringify
|
|
235
|
+
} from "@drakkar.software/starfish-protocol";
|
|
236
|
+
|
|
231
237
|
// src/types.ts
|
|
232
238
|
var ConflictError = class extends Error {
|
|
233
239
|
constructor() {
|
|
@@ -246,34 +252,111 @@ var StarfishHttpError = class extends Error {
|
|
|
246
252
|
|
|
247
253
|
// src/client.ts
|
|
248
254
|
var APPEND_DEFAULT_FIELD = "items";
|
|
255
|
+
function encodeCapAuth(cap) {
|
|
256
|
+
const json = stableStringify(cap);
|
|
257
|
+
if (typeof btoa === "function") {
|
|
258
|
+
return btoa(json);
|
|
259
|
+
}
|
|
260
|
+
const bufCtor = globalThis.Buffer;
|
|
261
|
+
if (bufCtor) return bufCtor.from(json, "utf-8").toString("base64");
|
|
262
|
+
throw new Error("No base64 encoder available");
|
|
263
|
+
}
|
|
249
264
|
var StarfishClient = class {
|
|
250
265
|
baseUrl;
|
|
251
|
-
|
|
266
|
+
capProvider;
|
|
252
267
|
fetch;
|
|
268
|
+
/**
|
|
269
|
+
* Installed client-side plugins. Currently stored as inert data; no
|
|
270
|
+
* hooks fire yet. Extensions can inspect this list if needed.
|
|
271
|
+
*/
|
|
272
|
+
plugins;
|
|
253
273
|
constructor(options) {
|
|
254
274
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
255
|
-
this.
|
|
275
|
+
this.capProvider = options.capProvider;
|
|
256
276
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
277
|
+
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Resolve the host portion of the URL the client will send to. The host
|
|
281
|
+
* is folded into the signed canonical input as the `h` field so the
|
|
282
|
+
* server can refuse a signature that was minted against a different
|
|
283
|
+
* Starfish host (replay-across-servers defence).
|
|
284
|
+
*
|
|
285
|
+
* When `baseUrl` is relative — e.g. the consumer passed a custom `fetch`
|
|
286
|
+
* that resolves relative URLs in its own context — there is no parseable
|
|
287
|
+
* host; we return `""` so signing still proceeds. The server-side
|
|
288
|
+
* verifier will also reconstruct host from its inbound URL, so the
|
|
289
|
+
* empty-host case still verifies symmetrically when both sides agree.
|
|
290
|
+
*/
|
|
291
|
+
signingHost() {
|
|
292
|
+
try {
|
|
293
|
+
return new URL(this.baseUrl).host;
|
|
294
|
+
} catch {
|
|
295
|
+
return "";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Build auth headers for a request. When a `capProvider` is set, signs the
|
|
300
|
+
* request with the device's Ed25519 private key and returns the v3 header
|
|
301
|
+
* set (`Authorization: Cap …`, `X-Starfish-Sig`, `X-Starfish-Ts`,
|
|
302
|
+
* `X-Starfish-Nonce`). Empty when no provider is configured (public reads).
|
|
303
|
+
*
|
|
304
|
+
* Body bytes signed MUST equal the bytes sent on the wire — callers pass
|
|
305
|
+
* the already-serialized body string here so signing and transmission agree.
|
|
306
|
+
* The host bound into the signature is derived from `baseUrl` once per call.
|
|
307
|
+
*/
|
|
308
|
+
async buildAuthHeaders(method, pathAndQuery, body) {
|
|
309
|
+
if (this.capProvider) {
|
|
310
|
+
const { cap, devEdPrivHex, pubHex } = await this.capProvider.getCap();
|
|
311
|
+
const req = {
|
|
312
|
+
method,
|
|
313
|
+
pathAndQuery,
|
|
314
|
+
body,
|
|
315
|
+
host: this.signingHost()
|
|
316
|
+
};
|
|
317
|
+
const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
|
|
318
|
+
const headers = {
|
|
319
|
+
Authorization: `Cap ${encodeCapAuth(cap)}`,
|
|
320
|
+
"X-Starfish-Sig": sig,
|
|
321
|
+
"X-Starfish-Ts": String(ts),
|
|
322
|
+
"X-Starfish-Nonce": nonce
|
|
323
|
+
};
|
|
324
|
+
if (pubHex !== void 0) headers["X-Starfish-Pub"] = pubHex;
|
|
325
|
+
return headers;
|
|
326
|
+
}
|
|
327
|
+
return {};
|
|
257
328
|
}
|
|
258
329
|
async pull(path, checkpointOrOptions) {
|
|
259
|
-
let
|
|
330
|
+
let pathAndQuery = path;
|
|
260
331
|
let appendField;
|
|
261
332
|
if (typeof checkpointOrOptions === "number") {
|
|
262
|
-
if (checkpointOrOptions)
|
|
333
|
+
if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
263
334
|
} else if (checkpointOrOptions != null) {
|
|
264
|
-
|
|
335
|
+
const opts = checkpointOrOptions;
|
|
336
|
+
const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
|
|
265
337
|
const params = new URLSearchParams();
|
|
266
|
-
if (
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
338
|
+
if (isPullOptions) {
|
|
339
|
+
if (opts.checkpoint != null && opts.checkpoint > 0) {
|
|
340
|
+
params.set("checkpoint", String(opts.checkpoint));
|
|
341
|
+
}
|
|
342
|
+
if (opts.withKeyring) {
|
|
343
|
+
params.set("withKeyring", "1");
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
|
|
347
|
+
if (opts.since != null) {
|
|
348
|
+
if (opts.since < 0) throw new Error("since must be non-negative");
|
|
349
|
+
params.set("checkpoint", String(opts.since));
|
|
350
|
+
}
|
|
351
|
+
if (opts.last != null) {
|
|
352
|
+
if (opts.last < 0) throw new Error("last must be non-negative");
|
|
353
|
+
params.set("last", String(opts.last));
|
|
354
|
+
}
|
|
273
355
|
}
|
|
274
|
-
if (params.size > 0)
|
|
356
|
+
if (params.size > 0) pathAndQuery += `?${params.toString()}`;
|
|
275
357
|
}
|
|
276
|
-
const
|
|
358
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
359
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
277
360
|
const res = await this.fetch(url, {
|
|
278
361
|
method: "GET",
|
|
279
362
|
headers: { Accept: "application/json", ...authHeaders }
|
|
@@ -293,16 +376,17 @@ var StarfishClient = class {
|
|
|
293
376
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
294
377
|
* @param data - The full document data to push
|
|
295
378
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
296
|
-
*
|
|
379
|
+
*
|
|
380
|
+
* v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
|
|
381
|
+
* and are produced by `SyncManager` when a `signer` is configured.
|
|
297
382
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
298
383
|
*/
|
|
299
|
-
async push(path, data, baseHash
|
|
384
|
+
async push(path, data, baseHash) {
|
|
300
385
|
const body = JSON.stringify({
|
|
301
386
|
data,
|
|
302
|
-
baseHash
|
|
303
|
-
...authorSignature && { authorSignature }
|
|
387
|
+
baseHash
|
|
304
388
|
});
|
|
305
|
-
const authHeaders =
|
|
389
|
+
const authHeaders = await this.buildAuthHeaders("POST", path, body);
|
|
306
390
|
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
307
391
|
method: "POST",
|
|
308
392
|
headers: {
|
|
@@ -325,7 +409,7 @@ var StarfishClient = class {
|
|
|
325
409
|
* Returns raw bytes with the content hash from the ETag header.
|
|
326
410
|
*/
|
|
327
411
|
async pullBlob(path) {
|
|
328
|
-
const authHeaders =
|
|
412
|
+
const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
|
|
329
413
|
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
330
414
|
method: "GET",
|
|
331
415
|
headers: { Accept: "*/*", ...authHeaders }
|
|
@@ -343,7 +427,7 @@ var StarfishClient = class {
|
|
|
343
427
|
* Binary collections use last-write-wins (no conflict detection).
|
|
344
428
|
*/
|
|
345
429
|
async pushBlob(path, data, contentType) {
|
|
346
|
-
const authHeaders =
|
|
430
|
+
const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
|
|
347
431
|
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
348
432
|
method: "POST",
|
|
349
433
|
headers: {
|
|
@@ -361,51 +445,7 @@ var StarfishClient = class {
|
|
|
361
445
|
};
|
|
362
446
|
|
|
363
447
|
// src/sync.ts
|
|
364
|
-
import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
|
|
365
|
-
|
|
366
|
-
// src/crypto.ts
|
|
367
|
-
import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
|
|
368
|
-
var ALGO = "AES-GCM";
|
|
369
|
-
function createEncryptor(secret, salt, info = "starfish-e2e") {
|
|
370
|
-
if (!secret) throw new Error("encryptionSecret must not be empty");
|
|
371
|
-
if (!salt) throw new Error("encryptionSalt must not be empty");
|
|
372
|
-
const keyPromise = deriveKey(secret, salt, info);
|
|
373
|
-
return {
|
|
374
|
-
async encrypt(data) {
|
|
375
|
-
const key = await keyPromise;
|
|
376
|
-
const c = getCrypto();
|
|
377
|
-
const b64 = getBase64();
|
|
378
|
-
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
379
|
-
const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
|
|
380
|
-
const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
|
|
381
|
-
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
382
|
-
combined.set(iv);
|
|
383
|
-
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
384
|
-
return { [ENCRYPTED_KEY]: b64.encode(combined) };
|
|
385
|
-
},
|
|
386
|
-
async decrypt(wrapper) {
|
|
387
|
-
const encoded = wrapper[ENCRYPTED_KEY];
|
|
388
|
-
if (typeof encoded !== "string") {
|
|
389
|
-
throw new Error("Expected encrypted data but received unencrypted document");
|
|
390
|
-
}
|
|
391
|
-
const key = await keyPromise;
|
|
392
|
-
const c = getCrypto();
|
|
393
|
-
const b64 = getBase64();
|
|
394
|
-
const combined = b64.decode(encoded);
|
|
395
|
-
if (combined.length < IV_BYTES) {
|
|
396
|
-
throw new Error("Encrypted data is too short");
|
|
397
|
-
}
|
|
398
|
-
const iv = combined.slice(0, IV_BYTES);
|
|
399
|
-
const ciphertext = combined.slice(IV_BYTES);
|
|
400
|
-
try {
|
|
401
|
-
const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
|
|
402
|
-
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
403
|
-
} catch (err) {
|
|
404
|
-
throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
};
|
|
408
|
-
}
|
|
448
|
+
import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
|
|
409
449
|
|
|
410
450
|
// src/validate.ts
|
|
411
451
|
var ValidationError = class extends Error {
|
|
@@ -430,7 +470,7 @@ var SyncManager = class {
|
|
|
430
470
|
onConflict;
|
|
431
471
|
maxRetries;
|
|
432
472
|
encryptor;
|
|
433
|
-
|
|
473
|
+
signer;
|
|
434
474
|
logger;
|
|
435
475
|
loggerName;
|
|
436
476
|
validate;
|
|
@@ -444,11 +484,11 @@ var SyncManager = class {
|
|
|
444
484
|
this.pushPath = options.pushPath;
|
|
445
485
|
this.onConflict = options.onConflict ?? deepMerge;
|
|
446
486
|
this.maxRetries = options.maxRetries ?? 3;
|
|
447
|
-
this.
|
|
487
|
+
this.signer = options.signer;
|
|
448
488
|
this.logger = options.logger;
|
|
449
489
|
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
450
490
|
this.validate = options.validate;
|
|
451
|
-
this.encryptor = options.encryptor ??
|
|
491
|
+
this.encryptor = options.encryptor ?? null;
|
|
452
492
|
}
|
|
453
493
|
abort() {
|
|
454
494
|
this.aborted = true;
|
|
@@ -508,15 +548,25 @@ var SyncManager = class {
|
|
|
508
548
|
let pendingData = data;
|
|
509
549
|
while (attempt <= this.maxRetries) {
|
|
510
550
|
try {
|
|
511
|
-
const
|
|
512
|
-
if (this.aborted) throw new AbortError();
|
|
513
|
-
const sig = this.signData ? await this.signData(stableStringify(payload)) : void 0;
|
|
551
|
+
const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
514
552
|
if (this.aborted) throw new AbortError();
|
|
553
|
+
let payload = sealed;
|
|
554
|
+
if (this.signer) {
|
|
555
|
+
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
556
|
+
if (this.aborted) throw new AbortError();
|
|
557
|
+
const canonical = stableStringify2(sealed);
|
|
558
|
+
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
559
|
+
if (this.aborted) throw new AbortError();
|
|
560
|
+
payload = {
|
|
561
|
+
...sealed,
|
|
562
|
+
authorPubkey: devEdPubHex,
|
|
563
|
+
authorSignature: getBase64().encode(sigBytes)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
515
566
|
const result = await this.client.push(
|
|
516
567
|
this.pushPath,
|
|
517
568
|
payload,
|
|
518
|
-
this.lastHash
|
|
519
|
-
sig
|
|
569
|
+
this.lastHash
|
|
520
570
|
);
|
|
521
571
|
if (this.aborted) throw new AbortError();
|
|
522
572
|
this.lastHash = result.hash;
|
|
@@ -790,15 +840,14 @@ function useSyncInit(config) {
|
|
|
790
840
|
}
|
|
791
841
|
const client = new StarfishClient({
|
|
792
842
|
baseUrl: config.serverUrl,
|
|
793
|
-
|
|
843
|
+
capProvider: config.capProvider,
|
|
794
844
|
fetch: config.fetch
|
|
795
845
|
});
|
|
796
846
|
const syncManager = new SyncManager({
|
|
797
847
|
client,
|
|
798
848
|
pullPath: config.pullPath,
|
|
799
849
|
pushPath: config.pushPath,
|
|
800
|
-
|
|
801
|
-
encryptionSalt: config.encryptionSalt,
|
|
850
|
+
encryptor: config.encryptor,
|
|
802
851
|
onConflict: config.onConflict,
|
|
803
852
|
logger: config.logger,
|
|
804
853
|
validate: config.validate
|
|
@@ -829,8 +878,7 @@ function useSyncInit(config) {
|
|
|
829
878
|
config?.serverUrl,
|
|
830
879
|
config?.pullPath,
|
|
831
880
|
config?.pushPath,
|
|
832
|
-
config?.
|
|
833
|
-
config?.encryptionSalt,
|
|
881
|
+
config?.encryptor,
|
|
834
882
|
config?.storeName
|
|
835
883
|
]);
|
|
836
884
|
return store;
|