@drakkar.software/starfish-client 2.2.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.
- 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 +125 -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 +131 -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 +48 -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,109 @@ 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 } = 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
|
+
return {
|
|
319
|
+
Authorization: `Cap ${encodeCapAuth(cap)}`,
|
|
320
|
+
"X-Starfish-Sig": sig,
|
|
321
|
+
"X-Starfish-Ts": String(ts),
|
|
322
|
+
"X-Starfish-Nonce": nonce
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return {};
|
|
257
326
|
}
|
|
258
327
|
async pull(path, checkpointOrOptions) {
|
|
259
|
-
let
|
|
328
|
+
let pathAndQuery = path;
|
|
260
329
|
let appendField;
|
|
261
330
|
if (typeof checkpointOrOptions === "number") {
|
|
262
|
-
if (checkpointOrOptions)
|
|
331
|
+
if (checkpointOrOptions) pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
263
332
|
} else if (checkpointOrOptions != null) {
|
|
264
|
-
|
|
333
|
+
const opts = checkpointOrOptions;
|
|
334
|
+
const isPullOptions = opts.withKeyring !== void 0 || opts.checkpoint !== void 0;
|
|
265
335
|
const params = new URLSearchParams();
|
|
266
|
-
if (
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
336
|
+
if (isPullOptions) {
|
|
337
|
+
if (opts.checkpoint != null && opts.checkpoint > 0) {
|
|
338
|
+
params.set("checkpoint", String(opts.checkpoint));
|
|
339
|
+
}
|
|
340
|
+
if (opts.withKeyring) {
|
|
341
|
+
params.set("withKeyring", "1");
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
|
|
345
|
+
if (opts.since != null) {
|
|
346
|
+
if (opts.since < 0) throw new Error("since must be non-negative");
|
|
347
|
+
params.set("checkpoint", String(opts.since));
|
|
348
|
+
}
|
|
349
|
+
if (opts.last != null) {
|
|
350
|
+
if (opts.last < 0) throw new Error("last must be non-negative");
|
|
351
|
+
params.set("last", String(opts.last));
|
|
352
|
+
}
|
|
273
353
|
}
|
|
274
|
-
if (params.size > 0)
|
|
354
|
+
if (params.size > 0) pathAndQuery += `?${params.toString()}`;
|
|
275
355
|
}
|
|
276
|
-
const
|
|
356
|
+
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
357
|
+
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
|
|
277
358
|
const res = await this.fetch(url, {
|
|
278
359
|
method: "GET",
|
|
279
360
|
headers: { Accept: "application/json", ...authHeaders }
|
|
@@ -293,16 +374,17 @@ var StarfishClient = class {
|
|
|
293
374
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
294
375
|
* @param data - The full document data to push
|
|
295
376
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
296
|
-
*
|
|
377
|
+
*
|
|
378
|
+
* v3 author fields (`authorPubkey` + `authorSignature`) live inside `data`
|
|
379
|
+
* and are produced by `SyncManager` when a `signer` is configured.
|
|
297
380
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
298
381
|
*/
|
|
299
|
-
async push(path, data, baseHash
|
|
382
|
+
async push(path, data, baseHash) {
|
|
300
383
|
const body = JSON.stringify({
|
|
301
384
|
data,
|
|
302
|
-
baseHash
|
|
303
|
-
...authorSignature && { authorSignature }
|
|
385
|
+
baseHash
|
|
304
386
|
});
|
|
305
|
-
const authHeaders =
|
|
387
|
+
const authHeaders = await this.buildAuthHeaders("POST", path, body);
|
|
306
388
|
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
307
389
|
method: "POST",
|
|
308
390
|
headers: {
|
|
@@ -325,7 +407,7 @@ var StarfishClient = class {
|
|
|
325
407
|
* Returns raw bytes with the content hash from the ETag header.
|
|
326
408
|
*/
|
|
327
409
|
async pullBlob(path) {
|
|
328
|
-
const authHeaders =
|
|
410
|
+
const authHeaders = await this.buildAuthHeaders("GET", path, void 0);
|
|
329
411
|
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
330
412
|
method: "GET",
|
|
331
413
|
headers: { Accept: "*/*", ...authHeaders }
|
|
@@ -343,7 +425,7 @@ var StarfishClient = class {
|
|
|
343
425
|
* Binary collections use last-write-wins (no conflict detection).
|
|
344
426
|
*/
|
|
345
427
|
async pushBlob(path, data, contentType) {
|
|
346
|
-
const authHeaders =
|
|
428
|
+
const authHeaders = await this.buildAuthHeaders("POST", path, void 0);
|
|
347
429
|
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
348
430
|
method: "POST",
|
|
349
431
|
headers: {
|
|
@@ -361,51 +443,7 @@ var StarfishClient = class {
|
|
|
361
443
|
};
|
|
362
444
|
|
|
363
445
|
// 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
|
-
}
|
|
446
|
+
import { deepMerge, getBase64, stableStringify as stableStringify2 } from "@drakkar.software/starfish-protocol";
|
|
409
447
|
|
|
410
448
|
// src/validate.ts
|
|
411
449
|
var ValidationError = class extends Error {
|
|
@@ -430,7 +468,7 @@ var SyncManager = class {
|
|
|
430
468
|
onConflict;
|
|
431
469
|
maxRetries;
|
|
432
470
|
encryptor;
|
|
433
|
-
|
|
471
|
+
signer;
|
|
434
472
|
logger;
|
|
435
473
|
loggerName;
|
|
436
474
|
validate;
|
|
@@ -444,11 +482,11 @@ var SyncManager = class {
|
|
|
444
482
|
this.pushPath = options.pushPath;
|
|
445
483
|
this.onConflict = options.onConflict ?? deepMerge;
|
|
446
484
|
this.maxRetries = options.maxRetries ?? 3;
|
|
447
|
-
this.
|
|
485
|
+
this.signer = options.signer;
|
|
448
486
|
this.logger = options.logger;
|
|
449
487
|
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
450
488
|
this.validate = options.validate;
|
|
451
|
-
this.encryptor = options.encryptor ??
|
|
489
|
+
this.encryptor = options.encryptor ?? null;
|
|
452
490
|
}
|
|
453
491
|
abort() {
|
|
454
492
|
this.aborted = true;
|
|
@@ -508,15 +546,25 @@ var SyncManager = class {
|
|
|
508
546
|
let pendingData = data;
|
|
509
547
|
while (attempt <= this.maxRetries) {
|
|
510
548
|
try {
|
|
511
|
-
const
|
|
512
|
-
if (this.aborted) throw new AbortError();
|
|
513
|
-
const sig = this.signData ? await this.signData(stableStringify(payload)) : void 0;
|
|
549
|
+
const sealed = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
|
|
514
550
|
if (this.aborted) throw new AbortError();
|
|
551
|
+
let payload = sealed;
|
|
552
|
+
if (this.signer) {
|
|
553
|
+
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
554
|
+
if (this.aborted) throw new AbortError();
|
|
555
|
+
const canonical = stableStringify2(sealed);
|
|
556
|
+
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
557
|
+
if (this.aborted) throw new AbortError();
|
|
558
|
+
payload = {
|
|
559
|
+
...sealed,
|
|
560
|
+
authorPubkey: devEdPubHex,
|
|
561
|
+
authorSignature: getBase64().encode(sigBytes)
|
|
562
|
+
};
|
|
563
|
+
}
|
|
515
564
|
const result = await this.client.push(
|
|
516
565
|
this.pushPath,
|
|
517
566
|
payload,
|
|
518
|
-
this.lastHash
|
|
519
|
-
sig
|
|
567
|
+
this.lastHash
|
|
520
568
|
);
|
|
521
569
|
if (this.aborted) throw new AbortError();
|
|
522
570
|
this.lastHash = result.hash;
|
|
@@ -790,15 +838,14 @@ function useSyncInit(config) {
|
|
|
790
838
|
}
|
|
791
839
|
const client = new StarfishClient({
|
|
792
840
|
baseUrl: config.serverUrl,
|
|
793
|
-
|
|
841
|
+
capProvider: config.capProvider,
|
|
794
842
|
fetch: config.fetch
|
|
795
843
|
});
|
|
796
844
|
const syncManager = new SyncManager({
|
|
797
845
|
client,
|
|
798
846
|
pullPath: config.pullPath,
|
|
799
847
|
pushPath: config.pushPath,
|
|
800
|
-
|
|
801
|
-
encryptionSalt: config.encryptionSalt,
|
|
848
|
+
encryptor: config.encryptor,
|
|
802
849
|
onConflict: config.onConflict,
|
|
803
850
|
logger: config.logger,
|
|
804
851
|
validate: config.validate
|
|
@@ -829,8 +876,7 @@ function useSyncInit(config) {
|
|
|
829
876
|
config?.serverUrl,
|
|
830
877
|
config?.pullPath,
|
|
831
878
|
config?.pushPath,
|
|
832
|
-
config?.
|
|
833
|
-
config?.encryptionSalt,
|
|
879
|
+
config?.encryptor,
|
|
834
880
|
config?.storeName
|
|
835
881
|
]);
|
|
836
882
|
return store;
|