@drakkar.software/starfish-client 3.0.0-alpha.21 → 3.0.0-alpha.23
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/dist/_crypto_helpers.d.ts +4 -0
- package/dist/append-log.js +267 -0
- 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.js +316 -37
- 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/keyring.d.ts +6 -0
- package/dist/keyring.js +26 -0
- package/dist/keyring.js.map +7 -0
- package/dist/mobile-lifecycle.js +41 -2
- package/dist/pairing.d.ts +6 -0
- package/dist/pairing.js +26 -0
- package/dist/pairing.js.map +7 -0
- package/dist/polling.js +2 -2
- package/dist/recipients.d.ts +6 -0
- package/dist/recipients.js +16 -0
- package/dist/recipients.js.map +7 -0
- package/dist/sync.js +68 -14
- package/package.json +2 -2
- package/dist/append.d.ts +0 -50
- package/dist/bindings/broadcast.d.ts +0 -19
- package/dist/bindings/broadcast.js +0 -65
- package/dist/bindings/react.d.ts +0 -12
- package/dist/bindings/react.js +0 -25
- package/dist/crypto.js +0 -49
- package/dist/entitlements.js +0 -41
- 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/sync.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { deepMerge,
|
|
1
|
+
import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, deepMerge, docAuthorCanonicalInput, getBase64, } from "@drakkar.software/starfish-protocol";
|
|
2
2
|
import { ConflictError } from "./types.js";
|
|
3
|
-
import {
|
|
3
|
+
import { stripPushPrefix } from "./client.js";
|
|
4
4
|
import { ValidationError } from "./validate.js";
|
|
5
|
+
export class AbortError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("SyncManager was aborted");
|
|
8
|
+
this.name = "AbortError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
5
11
|
export class SyncManager {
|
|
6
12
|
client;
|
|
7
13
|
pullPath;
|
|
@@ -9,28 +15,31 @@ export class SyncManager {
|
|
|
9
15
|
onConflict;
|
|
10
16
|
maxRetries;
|
|
11
17
|
encryptor;
|
|
12
|
-
|
|
18
|
+
signer;
|
|
13
19
|
logger;
|
|
14
20
|
loggerName;
|
|
15
21
|
validate;
|
|
16
22
|
lastHash = null;
|
|
17
23
|
lastCheckpoint = 0;
|
|
18
24
|
localData = {};
|
|
25
|
+
aborted = false;
|
|
19
26
|
constructor(options) {
|
|
20
27
|
this.client = options.client;
|
|
21
28
|
this.pullPath = options.pullPath;
|
|
22
29
|
this.pushPath = options.pushPath;
|
|
23
30
|
this.onConflict = options.onConflict ?? deepMerge;
|
|
24
31
|
this.maxRetries = options.maxRetries ?? 3;
|
|
25
|
-
this.
|
|
32
|
+
this.signer = options.signer;
|
|
26
33
|
this.logger = options.logger;
|
|
27
34
|
this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
|
|
28
35
|
this.validate = options.validate;
|
|
29
|
-
this.encryptor =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
this.encryptor = options.encryptor ?? null;
|
|
37
|
+
}
|
|
38
|
+
abort() {
|
|
39
|
+
this.aborted = true;
|
|
40
|
+
}
|
|
41
|
+
get isAborted() {
|
|
42
|
+
return this.aborted;
|
|
34
43
|
}
|
|
35
44
|
getData() {
|
|
36
45
|
return { ...this.localData };
|
|
@@ -38,16 +47,31 @@ export class SyncManager {
|
|
|
38
47
|
getHash() {
|
|
39
48
|
return this.lastHash;
|
|
40
49
|
}
|
|
50
|
+
/** Set the last-known server hash. Used by persistence layers to restore state across restarts. */
|
|
51
|
+
setHash(hash) {
|
|
52
|
+
this.lastHash = hash;
|
|
53
|
+
}
|
|
41
54
|
getCheckpoint() {
|
|
42
55
|
return this.lastCheckpoint;
|
|
43
56
|
}
|
|
44
57
|
async pull() {
|
|
58
|
+
if (this.aborted)
|
|
59
|
+
throw new AbortError();
|
|
45
60
|
this.logger?.pullStart(this.loggerName);
|
|
46
61
|
const start = performance.now();
|
|
47
62
|
try {
|
|
63
|
+
// NOTE: `SyncManager.pull` does NOT auto-enable `withKeyring`. Clients
|
|
64
|
+
// that drive the keyring helpers from `recipients.ts` and want to save
|
|
65
|
+
// the cold-start round-trip should call `client.pull(path, {withKeyring: true})`
|
|
66
|
+
// directly. We keep `SyncManager` keyring-agnostic so it stays usable
|
|
67
|
+
// for collections that don't use delegated encryption.
|
|
48
68
|
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
69
|
+
if (this.aborted)
|
|
70
|
+
throw new AbortError();
|
|
49
71
|
if (this.encryptor) {
|
|
50
72
|
const decrypted = await this.encryptor.decrypt(result.data);
|
|
73
|
+
if (this.aborted)
|
|
74
|
+
throw new AbortError();
|
|
51
75
|
this.localData = decrypted;
|
|
52
76
|
result.data = decrypted;
|
|
53
77
|
}
|
|
@@ -69,6 +93,8 @@ export class SyncManager {
|
|
|
69
93
|
}
|
|
70
94
|
}
|
|
71
95
|
async push(data) {
|
|
96
|
+
if (this.aborted)
|
|
97
|
+
throw new AbortError();
|
|
72
98
|
if (this.validate) {
|
|
73
99
|
const result = this.validate(data);
|
|
74
100
|
if (result !== true)
|
|
@@ -80,13 +106,33 @@ export class SyncManager {
|
|
|
80
106
|
let pendingData = data;
|
|
81
107
|
while (attempt <= this.maxRetries) {
|
|
82
108
|
try {
|
|
83
|
-
const
|
|
109
|
+
const sealed = this.encryptor
|
|
84
110
|
? await this.encryptor.encrypt(pendingData)
|
|
85
111
|
: pendingData;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
112
|
+
if (this.aborted)
|
|
113
|
+
throw new AbortError();
|
|
114
|
+
// v3.0 signer path: sign the document author proof over the doc-author
|
|
115
|
+
// canonical input (domain-tagged, bound to documentKey) and pass it as
|
|
116
|
+
// top-level body siblings of `data` (NOT inside `data`), where the server
|
|
117
|
+
// verifies it and stores the raw author pubkey.
|
|
118
|
+
let author;
|
|
119
|
+
if (this.signer) {
|
|
120
|
+
const { devEdPubHex, sign } = await this.signer.getSigner();
|
|
121
|
+
if (this.aborted)
|
|
122
|
+
throw new AbortError();
|
|
123
|
+
const documentKey = stripPushPrefix(this.pushPath);
|
|
124
|
+
const canonical = docAuthorCanonicalInput(documentKey, sealed);
|
|
125
|
+
const sigBytes = await sign(new TextEncoder().encode(canonical));
|
|
126
|
+
if (this.aborted)
|
|
127
|
+
throw new AbortError();
|
|
128
|
+
author = {
|
|
129
|
+
[AUTHOR_PUBKEY_FIELD]: devEdPubHex,
|
|
130
|
+
[AUTHOR_SIGNATURE_FIELD]: getBase64().encode(sigBytes),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const result = await this.client.push(this.pushPath, sealed, this.lastHash, author);
|
|
134
|
+
if (this.aborted)
|
|
135
|
+
throw new AbortError();
|
|
90
136
|
this.lastHash = result.hash;
|
|
91
137
|
this.lastCheckpoint = result.timestamp;
|
|
92
138
|
this.localData = pendingData;
|
|
@@ -94,6 +140,8 @@ export class SyncManager {
|
|
|
94
140
|
return result;
|
|
95
141
|
}
|
|
96
142
|
catch (err) {
|
|
143
|
+
if (err instanceof AbortError)
|
|
144
|
+
throw err;
|
|
97
145
|
if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
|
|
98
146
|
this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
|
|
99
147
|
throw err;
|
|
@@ -101,14 +149,20 @@ export class SyncManager {
|
|
|
101
149
|
this.logger?.conflict(this.loggerName, attempt + 1);
|
|
102
150
|
try {
|
|
103
151
|
const remote = await this.client.pull(this.pullPath);
|
|
152
|
+
if (this.aborted)
|
|
153
|
+
throw new AbortError();
|
|
104
154
|
const remoteData = this.encryptor
|
|
105
155
|
? await this.encryptor.decrypt(remote.data)
|
|
106
156
|
: remote.data;
|
|
157
|
+
if (this.aborted)
|
|
158
|
+
throw new AbortError();
|
|
107
159
|
this.lastHash = remote.hash;
|
|
108
160
|
this.lastCheckpoint = remote.timestamp;
|
|
109
161
|
pendingData = this.onConflict(pendingData, remoteData);
|
|
110
162
|
}
|
|
111
163
|
catch (resolveErr) {
|
|
164
|
+
if (resolveErr instanceof AbortError)
|
|
165
|
+
throw resolveErr;
|
|
112
166
|
const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
|
|
113
167
|
this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
|
|
114
168
|
throw resolveErr;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakkar.software/starfish-client",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.23",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/Drakkar-Software/starfish.git",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@drakkar.software/starfish-protocol": "3.0.0-alpha.
|
|
63
|
+
"@drakkar.software/starfish-protocol": "3.0.0-alpha.23"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@legendapp/state": "^2.0.0",
|
package/dist/append.d.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import type { StarfishClient } from "./client.js";
|
|
2
|
-
import type { PushSuccess } from "@drakkar.software/starfish-protocol";
|
|
3
|
-
/**
|
|
4
|
-
* Appends `item` to an append-only collection.
|
|
5
|
-
*
|
|
6
|
-
* Sends `{ data: item, baseHash: null }` — the server ignores `baseHash` for
|
|
7
|
-
* append-only collections (conflict detection is disabled or delegated to
|
|
8
|
-
* `checkLastItem` on the server side).
|
|
9
|
-
*
|
|
10
|
-
* ```ts
|
|
11
|
-
* await pushAppend(client, "/push/events", { type: "click", ts: Date.now() })
|
|
12
|
-
* ```
|
|
13
|
-
*/
|
|
14
|
-
export declare function pushAppend(client: StarfishClient, path: string, item: Record<string, unknown>): Promise<PushSuccess>;
|
|
15
|
-
export interface PullAppendListOptions {
|
|
16
|
-
/** Array field name. Defaults to `"items"` (server default). */
|
|
17
|
-
field?: string;
|
|
18
|
-
/**
|
|
19
|
-
* Return only items appended after this timestamp (milliseconds since epoch).
|
|
20
|
-
* Sent as `?checkpoint=<since>`. Omit for a full pull.
|
|
21
|
-
*/
|
|
22
|
-
since?: number;
|
|
23
|
-
/**
|
|
24
|
-
* Return only the last K items. Applied after the `since` filter.
|
|
25
|
-
* Useful for "latest N entries" queries without a tracked checkpoint.
|
|
26
|
-
* Sent as `?last=<K>`.
|
|
27
|
-
*/
|
|
28
|
-
last?: number;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Pulls the stored item array from an append-only collection.
|
|
32
|
-
*
|
|
33
|
-
* Returns `data[field]` filtered to an array; returns `[]` when the document
|
|
34
|
-
* does not exist yet or the field is absent / not an array.
|
|
35
|
-
*
|
|
36
|
-
* Pass `{ since: ts }` for incremental pulls — only items appended after `ts`
|
|
37
|
-
* are returned (requires per-item timestamps on the server, available from 2.0.0).
|
|
38
|
-
*
|
|
39
|
-
* ```ts
|
|
40
|
-
* // Full pull
|
|
41
|
-
* const events = await pullAppendList(client, "/pull/events")
|
|
42
|
-
*
|
|
43
|
-
* // Incremental pull
|
|
44
|
-
* const newEvents = await pullAppendList(client, "/pull/events", { since: lastSyncTs })
|
|
45
|
-
*
|
|
46
|
-
* // Custom field name
|
|
47
|
-
* const logs = await pullAppendList(client, "/pull/audit", { field: "logs" })
|
|
48
|
-
* ```
|
|
49
|
-
*/
|
|
50
|
-
export declare function pullAppendList<T = unknown>(client: StarfishClient, path: string, options?: PullAppendListOptions): Promise<T[]>;
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { StoreApi } from "zustand/vanilla";
|
|
2
|
-
import type { StarfishStore } from "./zustand.js";
|
|
3
|
-
/**
|
|
4
|
-
* Syncs a Zustand Starfish store across browser tabs using BroadcastChannel.
|
|
5
|
-
* Returns a cleanup function that closes the channel.
|
|
6
|
-
*/
|
|
7
|
-
export declare function setupBroadcastSync(store: StoreApi<StarfishStore>, name: string): () => void;
|
|
8
|
-
/**
|
|
9
|
-
* Syncs a Zustand Starfish store across browser tabs using storage events.
|
|
10
|
-
* Fallback for environments without BroadcastChannel.
|
|
11
|
-
* Returns a cleanup function.
|
|
12
|
-
*/
|
|
13
|
-
export declare function setupStorageFallback(store: StoreApi<StarfishStore>, name: string): () => void;
|
|
14
|
-
/**
|
|
15
|
-
* Auto-detects the best cross-tab sync mechanism and sets it up.
|
|
16
|
-
* Uses BroadcastChannel when available, falls back to storage events.
|
|
17
|
-
* Returns a cleanup function.
|
|
18
|
-
*/
|
|
19
|
-
export declare function setupCrossTabSync(store: StoreApi<StarfishStore>, name: string): () => void;
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Syncs a Zustand Starfish store across browser tabs using BroadcastChannel.
|
|
3
|
-
* Returns a cleanup function that closes the channel.
|
|
4
|
-
*/
|
|
5
|
-
export function setupBroadcastSync(store, name) {
|
|
6
|
-
const channel = new BroadcastChannel(`starfish-${name}`);
|
|
7
|
-
let lastReceivedData = null;
|
|
8
|
-
channel.onmessage = (event) => {
|
|
9
|
-
lastReceivedData = event.data.data;
|
|
10
|
-
store.setState({ data: event.data.data, dirty: event.data.dirty });
|
|
11
|
-
};
|
|
12
|
-
const unsub = store.subscribe((state, prev) => {
|
|
13
|
-
if (state.data === lastReceivedData)
|
|
14
|
-
return;
|
|
15
|
-
if (state.data !== prev.data || state.dirty !== prev.dirty) {
|
|
16
|
-
channel.postMessage({ data: state.data, dirty: state.dirty });
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
return () => {
|
|
20
|
-
unsub();
|
|
21
|
-
channel.close();
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Syncs a Zustand Starfish store across browser tabs using storage events.
|
|
26
|
-
* Fallback for environments without BroadcastChannel.
|
|
27
|
-
* Returns a cleanup function.
|
|
28
|
-
*/
|
|
29
|
-
export function setupStorageFallback(store, name) {
|
|
30
|
-
const storageKey = `starfish-broadcast-${name}`;
|
|
31
|
-
let lastReceivedData = null;
|
|
32
|
-
const onStorage = (e) => {
|
|
33
|
-
if (e.key !== storageKey || !e.newValue)
|
|
34
|
-
return;
|
|
35
|
-
const payload = JSON.parse(e.newValue);
|
|
36
|
-
lastReceivedData = payload.data;
|
|
37
|
-
store.setState({ data: payload.data, dirty: payload.dirty });
|
|
38
|
-
};
|
|
39
|
-
globalThis.addEventListener("storage", onStorage);
|
|
40
|
-
const unsub = store.subscribe((state, prev) => {
|
|
41
|
-
if (state.data === lastReceivedData)
|
|
42
|
-
return;
|
|
43
|
-
if (state.data !== prev.data || state.dirty !== prev.dirty) {
|
|
44
|
-
localStorage.setItem(storageKey, JSON.stringify({ data: state.data, dirty: state.dirty }));
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
return () => {
|
|
48
|
-
unsub();
|
|
49
|
-
globalThis.removeEventListener("storage", onStorage);
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Auto-detects the best cross-tab sync mechanism and sets it up.
|
|
54
|
-
* Uses BroadcastChannel when available, falls back to storage events.
|
|
55
|
-
* Returns a cleanup function.
|
|
56
|
-
*/
|
|
57
|
-
export function setupCrossTabSync(store, name) {
|
|
58
|
-
if (typeof BroadcastChannel !== "undefined") {
|
|
59
|
-
return setupBroadcastSync(store, name);
|
|
60
|
-
}
|
|
61
|
-
if (typeof globalThis.addEventListener === "function" && typeof localStorage !== "undefined") {
|
|
62
|
-
return setupStorageFallback(store, name);
|
|
63
|
-
}
|
|
64
|
-
return () => { };
|
|
65
|
-
}
|
package/dist/bindings/react.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { StoreApi } from "zustand/vanilla";
|
|
2
|
-
import type { StarfishStore, StarfishState } from "./zustand.js";
|
|
3
|
-
/** Derived sync status for UI display. */
|
|
4
|
-
export type SyncStatus = "synced" | "syncing" | "pending" | "error" | "offline";
|
|
5
|
-
/** Derive a single sync status from store state. */
|
|
6
|
-
export declare function deriveSyncStatus(state: StarfishState): SyncStatus;
|
|
7
|
-
/** Use the full Starfish store state and actions. */
|
|
8
|
-
export declare function useStarfish(store: StoreApi<StarfishStore>): StarfishStore;
|
|
9
|
-
/** Use only the synced data, with an optional selector for fine-grained subscriptions. */
|
|
10
|
-
export declare function useStarfishData<T = Record<string, unknown>>(store: StoreApi<StarfishStore>, selector?: (data: Record<string, unknown>) => T): T;
|
|
11
|
-
/** Use the derived sync status (synced | syncing | pending | error | offline). */
|
|
12
|
-
export declare function useSyncStatus(store: StoreApi<StarfishStore>): SyncStatus;
|
package/dist/bindings/react.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { useStore } from "zustand";
|
|
2
|
-
/** Derive a single sync status from store state. */
|
|
3
|
-
export function deriveSyncStatus(state) {
|
|
4
|
-
if (!state.online)
|
|
5
|
-
return "offline";
|
|
6
|
-
if (state.error)
|
|
7
|
-
return "error";
|
|
8
|
-
if (state.syncing)
|
|
9
|
-
return "syncing";
|
|
10
|
-
if (state.dirty)
|
|
11
|
-
return "pending";
|
|
12
|
-
return "synced";
|
|
13
|
-
}
|
|
14
|
-
/** Use the full Starfish store state and actions. */
|
|
15
|
-
export function useStarfish(store) {
|
|
16
|
-
return useStore(store);
|
|
17
|
-
}
|
|
18
|
-
/** Use only the synced data, with an optional selector for fine-grained subscriptions. */
|
|
19
|
-
export function useStarfishData(store, selector) {
|
|
20
|
-
return useStore(store, (state) => selector ? selector(state.data) : state.data);
|
|
21
|
-
}
|
|
22
|
-
/** Use the derived sync status (synced | syncing | pending | error | offline). */
|
|
23
|
-
export function useSyncStatus(store) {
|
|
24
|
-
return useStore(store, deriveSyncStatus);
|
|
25
|
-
}
|
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
|
-
}
|
package/dist/entitlements.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { StarfishHttpError } from "./types.js";
|
|
2
|
-
/**
|
|
3
|
-
* Fetches the list of feature slugs from a user's entitlement document.
|
|
4
|
-
*
|
|
5
|
-
* Returns an empty array if the document does not exist yet or the features
|
|
6
|
-
* field is absent — so callers never need to handle a 404.
|
|
7
|
-
*
|
|
8
|
-
* ```ts
|
|
9
|
-
* import { pullEntitlements } from "@drakkar.software/starfish-client"
|
|
10
|
-
*
|
|
11
|
-
* const features = await pullEntitlements(client, userId)
|
|
12
|
-
* // e.g. ["premium-package-1", "paid-cloud-sync"]
|
|
13
|
-
*
|
|
14
|
-
* if (features.includes("paid-cloud-sync")) {
|
|
15
|
-
* // unlock cloud sync UI
|
|
16
|
-
* }
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* The path template must match the server-side collection's `storagePath`.
|
|
20
|
-
* With the recommended default config:
|
|
21
|
-
* ```ts
|
|
22
|
-
* { storagePath: "users/{identity}/entitlements" }
|
|
23
|
-
* // → path: "/pull/users/{userId}/entitlements" (default)
|
|
24
|
-
* ```
|
|
25
|
-
*/
|
|
26
|
-
export async function pullEntitlements(client, userId, opts) {
|
|
27
|
-
const path = (opts?.path ?? "/pull/users/{userId}/entitlements").replace("{userId}", userId);
|
|
28
|
-
const field = opts?.field ?? "features";
|
|
29
|
-
try {
|
|
30
|
-
const result = await client.pull(path);
|
|
31
|
-
const list = result.data?.[field];
|
|
32
|
-
if (!Array.isArray(list))
|
|
33
|
-
return [];
|
|
34
|
-
return list.filter((s) => typeof s === "string");
|
|
35
|
-
}
|
|
36
|
-
catch (err) {
|
|
37
|
-
if (err instanceof StarfishHttpError && err.status === 404)
|
|
38
|
-
return [];
|
|
39
|
-
throw err;
|
|
40
|
-
}
|
|
41
|
-
}
|
package/dist/group-crypto.d.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Group encryption utilities for Starfish.
|
|
3
|
-
*
|
|
4
|
-
* Enables multiple users to share a common encrypted collection without sharing
|
|
5
|
-
* a passphrase. Each member holds their own credentials; a Group Encryption Key
|
|
6
|
-
* (GEK) is distributed per-member using X25519 ECDH key agreement.
|
|
7
|
-
*
|
|
8
|
-
* Typical flow:
|
|
9
|
-
* 1. Each user calls `deriveCredentials(passphrase)` — now includes groupPublicKey / groupPrivateKey.
|
|
10
|
-
* 2. Admin calls `createGroupKeyring(...)` to create a keyring document.
|
|
11
|
-
* 3. Members call `createGroupEncryptor(keyringData, myIdentity, myPrivateKey)` to get an Encryptor.
|
|
12
|
-
* 4. The Encryptor is passed to SyncManager via the `encryptor` option.
|
|
13
|
-
*/
|
|
14
|
-
import type { Encryptor } from "./crypto.js";
|
|
15
|
-
/** An ECDH key pair used for group encryption. Hex-encoded for easy serialization. */
|
|
16
|
-
export interface GroupKeyPair {
|
|
17
|
-
/** Hex-encoded X25519 private key (32 bytes). Keep secret — never store on server. */
|
|
18
|
-
privateKey: string;
|
|
19
|
-
/** Hex-encoded X25519 public key (32 bytes). Safe to publish. */
|
|
20
|
-
publicKey: string;
|
|
21
|
-
}
|
|
22
|
-
/** One epoch's wrapped keys: each member's GEK encrypted to their public key. */
|
|
23
|
-
export interface EpochKeyring {
|
|
24
|
-
/** The admin's hex-encoded X25519 public key (used for ECDH by members). */
|
|
25
|
-
adminPublicKey: string;
|
|
26
|
-
/** Map from member identity (userId) → base64(IV || AES-GCM(GEK)) */
|
|
27
|
-
wrappedKeys: Record<string, string>;
|
|
28
|
-
}
|
|
29
|
-
/** The full keyring document stored in a Starfish collection. Push this with any SyncManager. */
|
|
30
|
-
export interface GroupKeyring {
|
|
31
|
-
/** The epoch number currently used for new encryptions. */
|
|
32
|
-
currentEpoch: number;
|
|
33
|
-
/** All epochs. Members unwrap the GEK for whichever epoch a document was encrypted with. */
|
|
34
|
-
epochs: Record<string, EpochKeyring>;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Derives a deterministic X25519 key pair from a passphrase + userId.
|
|
38
|
-
*
|
|
39
|
-
* The derivation uses SHA-256 with a fixed domain separator so it is distinct
|
|
40
|
-
* from the auth token and encryption key derivations. Same passphrase + userId
|
|
41
|
-
* always produces the same key pair on any device (stateless).
|
|
42
|
-
*/
|
|
43
|
-
export declare function deriveGroupKeyPair(passphrase: string, userId: string): Promise<GroupKeyPair>;
|
|
44
|
-
/** Generates a random 256-bit Group Encryption Key as a hex string. */
|
|
45
|
-
export declare function generateGroupKey(): string;
|
|
46
|
-
/**
|
|
47
|
-
* Wraps a GEK for a specific member using ECDH key agreement.
|
|
48
|
-
*
|
|
49
|
-
* The wrapper (admin) and member each have an X25519 key pair. ECDH between
|
|
50
|
-
* `wrapperPrivateKey` and `memberPublicKey` produces a shared secret, which is
|
|
51
|
-
* used to derive an AES-256-GCM key that encrypts the GEK.
|
|
52
|
-
*
|
|
53
|
-
* @returns base64(IV || AES-GCM-ciphertext)
|
|
54
|
-
*/
|
|
55
|
-
export declare function wrapGroupKey(gek: string, memberPublicKey: string, wrapperPrivateKey: string): Promise<string>;
|
|
56
|
-
/**
|
|
57
|
-
* Unwraps a GEK using the member's own private key and the admin's public key.
|
|
58
|
-
*
|
|
59
|
-
* ECDH between `memberPrivateKey` and `adminPublicKey` yields the same shared
|
|
60
|
-
* secret as the wrapping step, so the same AES key is derived and the GEK is
|
|
61
|
-
* recovered.
|
|
62
|
-
*
|
|
63
|
-
* @returns GEK as a hex string
|
|
64
|
-
*/
|
|
65
|
-
export declare function unwrapGroupKey(wrapped: string, memberPrivateKey: string, adminPublicKey: string): Promise<string>;
|
|
66
|
-
/**
|
|
67
|
-
* Creates a new group keyring document with epoch 1.
|
|
68
|
-
*
|
|
69
|
-
* @param adminKeyPair The admin's key pair (from `deriveGroupKeyPair` or `deriveCredentials`)
|
|
70
|
-
* @param members Map from member identity (userId) → hex public key
|
|
71
|
-
* @param gek Optional GEK to use; generated randomly if omitted
|
|
72
|
-
* @returns The keyring document and the raw GEK (admin keeps the GEK to add future members)
|
|
73
|
-
*/
|
|
74
|
-
export declare function createGroupKeyring(adminKeyPair: GroupKeyPair, members: Record<string, string>, gek?: string): Promise<{
|
|
75
|
-
keyring: GroupKeyring;
|
|
76
|
-
gek: string;
|
|
77
|
-
}>;
|
|
78
|
-
/**
|
|
79
|
-
* Adds a new member to the current epoch of an existing keyring.
|
|
80
|
-
*
|
|
81
|
-
* The admin supplies the current GEK (returned by `createGroupKeyring` or
|
|
82
|
-
* `rotateGroupKey`) and their key pair to wrap it for the new member.
|
|
83
|
-
* This does NOT rotate the GEK — the new member can read all existing
|
|
84
|
-
* documents encrypted with the current epoch key.
|
|
85
|
-
*
|
|
86
|
-
* Only the admin (whose `publicKey` matches `epochKeyring.adminPublicKey`) can
|
|
87
|
-
* add members, because all wrapped entries must use the same ECDH key pair.
|
|
88
|
-
*/
|
|
89
|
-
export declare function addGroupMember(keyring: GroupKeyring, adminKeyPair: GroupKeyPair, currentGek: string, newMemberId: string, newMemberPublicKey: string): Promise<GroupKeyring>;
|
|
90
|
-
/**
|
|
91
|
-
* Rotates the group key, creating a new epoch.
|
|
92
|
-
*
|
|
93
|
-
* Used when removing a member. The removed member retains their old epoch key
|
|
94
|
-
* (and can still read old documents), but cannot read new documents.
|
|
95
|
-
*
|
|
96
|
-
* @param remainingMembers Map from identity → hex public key for members who keep access
|
|
97
|
-
*/
|
|
98
|
-
export declare function rotateGroupKey(keyring: GroupKeyring, adminKeyPair: GroupKeyPair, remainingMembers: Record<string, string>, newGek?: string): Promise<{
|
|
99
|
-
keyring: GroupKeyring;
|
|
100
|
-
gek: string;
|
|
101
|
-
}>;
|
|
102
|
-
/**
|
|
103
|
-
* Creates an Encryptor that can decrypt any epoch and encrypts with the current epoch.
|
|
104
|
-
*
|
|
105
|
-
* Wire format: `{ _encrypted: "base64(IV || ciphertext)", _epoch: N }`
|
|
106
|
-
*
|
|
107
|
-
* @param keyring The keyring document fetched from Starfish
|
|
108
|
-
* @param myIdentity The caller's userId (to locate their wrapped key in each epoch)
|
|
109
|
-
* @param myPrivateKey The caller's hex-encoded X25519 private key
|
|
110
|
-
*/
|
|
111
|
-
export declare function createGroupEncryptor(keyring: GroupKeyring, myIdentity: string, myPrivateKey: string): Promise<Encryptor>;
|