@drakkar.software/starfish-client 3.0.0-alpha.16 → 3.0.0-alpha.18
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/append.d.ts +50 -0
- package/dist/bindings/broadcast.d.ts +19 -0
- package/dist/bindings/broadcast.js +65 -0
- package/dist/bindings/react.d.ts +12 -0
- package/dist/bindings/react.js +25 -0
- package/dist/client.js +37 -316
- package/dist/crypto.js +49 -0
- package/dist/entitlements.js +41 -0
- package/dist/group-crypto.d.ts +111 -0
- package/dist/group-crypto.js +205 -0
- package/dist/group-crypto.js.map +7 -0
- package/dist/identity.d.ts +82 -4
- package/dist/identity.js +354 -2
- package/dist/identity.js.map +4 -4
- package/dist/mobile-lifecycle.js +2 -41
- package/dist/polling.js +2 -2
- package/dist/sync.js +14 -68
- package/package.json +2 -2
- package/dist/_crypto_helpers.d.ts +0 -4
- package/dist/append-log.js +0 -267
- package/dist/cap-mint.d.ts +0 -20
- package/dist/cap-mint.js +0 -12
- package/dist/cap-mint.js.map +0 -7
- package/dist/directory.d.ts +0 -9
- package/dist/directory.js +0 -24
- package/dist/directory.js.map +0 -7
- package/dist/keyring.d.ts +0 -6
- package/dist/keyring.js +0 -26
- package/dist/keyring.js.map +0 -7
- package/dist/pairing.d.ts +0 -6
- package/dist/pairing.js +0 -26
- package/dist/pairing.js.map +0 -7
- package/dist/recipients.d.ts +0 -6
- package/dist/recipients.js +0 -16
- package/dist/recipients.js.map +0 -7
package/dist/append.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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[]>;
|
|
@@ -0,0 +1,19 @@
|
|
|
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;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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;
|
|
@@ -0,0 +1,25 @@
|
|
|
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/client.js
CHANGED
|
@@ -1,277 +1,60 @@
|
|
|
1
|
-
import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, DATA_FIELD, TS_FIELD, BASE_HASH_FIELD, PUSH_PATH_PREFIX, HEADER_AUTHORIZATION, HEADER_SIG, HEADER_TS, HEADER_NONCE, HEADER_PUB, HEADER_CONTENT_TYPE, HEADER_ACCEPT, signAppendAuthor, signRequest, stableStringify, } from "@drakkar.software/starfish-protocol";
|
|
2
1
|
import { ConflictError, StarfishHttpError } from "./types.js";
|
|
3
|
-
const APPEND_DEFAULT_FIELD = "items";
|
|
4
|
-
/** The storage `documentKey` for a push `path`: the path with the `/push/`
|
|
5
|
-
* action prefix stripped (the namespace lives only in the URL). The author
|
|
6
|
-
* signature binds to this key. */
|
|
7
|
-
export function stripPushPrefix(path) {
|
|
8
|
-
return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
|
|
9
|
-
}
|
|
10
|
-
/**
|
|
11
|
-
* Base64-encode the canonical stable-stringification of a cap-cert.
|
|
12
|
-
*
|
|
13
|
-
* Used as the value of the `Authorization: Cap <…>` header in v3.0. We rely
|
|
14
|
-
* on the host's `btoa` for browsers and fall back to `Buffer` in Node so the
|
|
15
|
-
* client stays free of native dependencies.
|
|
16
|
-
*/
|
|
17
|
-
function encodeCapAuth(cap) {
|
|
18
|
-
const json = stableStringify(cap);
|
|
19
|
-
if (typeof btoa === "function") {
|
|
20
|
-
return btoa(json);
|
|
21
|
-
}
|
|
22
|
-
const bufCtor = globalThis.Buffer;
|
|
23
|
-
if (bufCtor)
|
|
24
|
-
return bufCtor.from(json, "utf-8").toString("base64");
|
|
25
|
-
throw new Error("No base64 encoder available");
|
|
26
|
-
}
|
|
27
2
|
/**
|
|
28
3
|
* Low-level HTTP client for the Starfish sync protocol.
|
|
29
4
|
* Handles auth headers and response parsing.
|
|
30
5
|
*/
|
|
31
6
|
export class StarfishClient {
|
|
32
7
|
baseUrl;
|
|
33
|
-
|
|
34
|
-
capProvider;
|
|
8
|
+
auth;
|
|
35
9
|
fetch;
|
|
36
|
-
/**
|
|
37
|
-
* Installed client-side plugins. Currently stored as inert data; no
|
|
38
|
-
* hooks fire yet. Extensions can inspect this list if needed.
|
|
39
|
-
*/
|
|
40
|
-
plugins;
|
|
41
10
|
constructor(options) {
|
|
42
11
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
43
|
-
|
|
44
|
-
// doesn't produce a malformed `/v1//…` path.
|
|
45
|
-
this.namespace = options.namespace || undefined;
|
|
46
|
-
this.capProvider = options.capProvider;
|
|
12
|
+
this.auth = options.auth;
|
|
47
13
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
48
|
-
this.plugins = options.plugins ? [...options.plugins] : [];
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Resolve the host portion of the URL the client will send to. The host
|
|
52
|
-
* is folded into the signed canonical input as the `h` field so the
|
|
53
|
-
* server can refuse a signature that was minted against a different
|
|
54
|
-
* Starfish host (replay-across-servers defence).
|
|
55
|
-
*
|
|
56
|
-
* When `baseUrl` is relative — e.g. the consumer passed a custom `fetch`
|
|
57
|
-
* that resolves relative URLs in its own context — there is no parseable
|
|
58
|
-
* host; we return `""` so signing still proceeds. The server-side
|
|
59
|
-
* verifier will also reconstruct host from its inbound URL, so the
|
|
60
|
-
* empty-host case still verifies symmetrically when both sides agree.
|
|
61
|
-
*/
|
|
62
|
-
signingHost() {
|
|
63
|
-
try {
|
|
64
|
-
return new URL(this.baseUrl).host;
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return "";
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Rewrite a request path for the configured namespace. A no-op when no
|
|
72
|
-
* namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
|
|
73
|
-
* (the `/v1` protocol-version segment is part of the namespaced route, matching
|
|
74
|
-
* the Python client and the server's namespace mount).
|
|
75
|
-
*
|
|
76
|
-
* Applied to the path used for BOTH the signature and the URL so the canonical
|
|
77
|
-
* path the client signs equals the path the server reconstructs from the URL.
|
|
78
|
-
* Covers SDK-helper-built paths too — that's the point: a namespace-unaware
|
|
79
|
-
* helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
|
|
80
|
-
*/
|
|
81
|
-
applyNamespace(path) {
|
|
82
|
-
return this.namespace ? `/v1/${this.namespace}${path}` : path;
|
|
83
14
|
}
|
|
84
15
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* `X-Starfish-Nonce`). Empty when no provider is configured (public reads).
|
|
89
|
-
*
|
|
90
|
-
* Body bytes signed MUST equal the bytes sent on the wire — callers pass
|
|
91
|
-
* the already-serialized body string here so signing and transmission agree.
|
|
92
|
-
* The host bound into the signature is derived from `baseUrl` once per call.
|
|
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)
|
|
93
19
|
*/
|
|
94
|
-
async
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
* Build the request-signing headers from an ALREADY-fetched cap context. Split
|
|
102
|
-
* out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
|
|
103
|
-
* reuse it for BOTH the author signature (over the element data) and the
|
|
104
|
-
* request signature (over the body), without redeeming the cap twice — a
|
|
105
|
-
* second `getCap()` could rotate keys and break the `authorPubkey ===
|
|
106
|
-
* presenter` bind the server checks.
|
|
107
|
-
*/
|
|
108
|
-
async capRequestHeaders(capCtx, method, pathAndQuery, body) {
|
|
109
|
-
const { cap, devEdPrivHex, pubHex } = capCtx;
|
|
110
|
-
const req = {
|
|
111
|
-
method,
|
|
112
|
-
pathAndQuery,
|
|
113
|
-
body,
|
|
114
|
-
host: this.signingHost(),
|
|
115
|
-
};
|
|
116
|
-
const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
|
|
117
|
-
const headers = {
|
|
118
|
-
[HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
|
|
119
|
-
[HEADER_SIG]: sig,
|
|
120
|
-
[HEADER_TS]: String(ts),
|
|
121
|
-
[HEADER_NONCE]: nonce,
|
|
122
|
-
};
|
|
123
|
-
// Audience (public-link) caps bind no single subject, so the server needs
|
|
124
|
-
// the presenter's pubkey to verify the signature and check the allow-list.
|
|
125
|
-
if (pubHex !== undefined)
|
|
126
|
-
headers[HEADER_PUB] = pubHex;
|
|
127
|
-
return headers;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Resolve the author public key to attach to a signed append: the redeemer's
|
|
131
|
-
* `pubHex` for an audience cap, else the cert subject `cap.sub` for a
|
|
132
|
-
* device/member cap. This is the SAME key that signs the request, so a server
|
|
133
|
-
* enforcing author proof can bind the stored element to its writer. Returns
|
|
134
|
-
* undefined only for a (malformed) cap with neither — the append then goes
|
|
135
|
-
* unsigned and a server requiring signatures rejects it.
|
|
136
|
-
*/
|
|
137
|
-
appendAuthorKey(capCtx) {
|
|
138
|
-
const { cap, pubHex } = capCtx;
|
|
139
|
-
const authorPubHex = pubHex ?? cap.sub;
|
|
140
|
-
if (authorPubHex === undefined)
|
|
141
|
-
return null;
|
|
142
|
-
return { authorPubHex };
|
|
143
|
-
}
|
|
144
|
-
async pull(path, checkpointOrOptions) {
|
|
145
|
-
let pathAndQuery = this.applyNamespace(path);
|
|
146
|
-
let appendField;
|
|
147
|
-
if (typeof checkpointOrOptions === "number") {
|
|
148
|
-
if (checkpointOrOptions)
|
|
149
|
-
pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
|
|
150
|
-
}
|
|
151
|
-
else if (checkpointOrOptions != null) {
|
|
152
|
-
// Disambiguate AppendPullOptions vs PullOptions.
|
|
153
|
-
//
|
|
154
|
-
// PullOptions are identified by the presence of `withKeyring` or
|
|
155
|
-
// `checkpoint` keys (which AppendPullOptions does not have — append
|
|
156
|
-
// uses `since`, not `checkpoint`). Anything else, including an empty
|
|
157
|
-
// `{}` object, retains the historical behavior of AppendPullOptions
|
|
158
|
-
// (extracts `data.items` with `?` query).
|
|
159
|
-
const opts = checkpointOrOptions;
|
|
160
|
-
const isPullOptions = opts.withKeyring !== undefined || opts.checkpoint !== undefined;
|
|
161
|
-
const params = new URLSearchParams();
|
|
162
|
-
if (isPullOptions) {
|
|
163
|
-
if (opts.checkpoint != null && opts.checkpoint > 0) {
|
|
164
|
-
params.set("checkpoint", String(opts.checkpoint));
|
|
165
|
-
}
|
|
166
|
-
if (opts.withKeyring) {
|
|
167
|
-
params.set("withKeyring", "1");
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
|
|
172
|
-
if (opts.since != null) {
|
|
173
|
-
if (opts.since < 0)
|
|
174
|
-
throw new Error("since must be non-negative");
|
|
175
|
-
params.set("checkpoint", String(opts.since));
|
|
176
|
-
}
|
|
177
|
-
if (opts.last != null) {
|
|
178
|
-
if (opts.last < 0)
|
|
179
|
-
throw new Error("last must be non-negative");
|
|
180
|
-
params.set("last", String(opts.last));
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
if (params.size > 0)
|
|
184
|
-
pathAndQuery += `?${params.toString()}`;
|
|
185
|
-
}
|
|
186
|
-
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
187
|
-
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, undefined);
|
|
188
|
-
const res = await this.fetch(url, {
|
|
189
|
-
method: "GET",
|
|
190
|
-
headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
|
|
191
|
-
});
|
|
192
|
-
if (!res.ok) {
|
|
193
|
-
throw new StarfishHttpError(res.status, await res.text());
|
|
194
|
-
}
|
|
195
|
-
const result = await res.json();
|
|
196
|
-
if (appendField !== undefined) {
|
|
197
|
-
const list = result.data?.[appendField];
|
|
198
|
-
return (Array.isArray(list) ? list : []);
|
|
199
|
-
}
|
|
200
|
-
return result;
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Pull several documents in one round-trip via `/batch/pull`. `collections` is
|
|
204
|
-
* the list of distinct collection names; `opts.params` supplies, per collection,
|
|
205
|
-
* an ARRAY of path-param sets — one per document to read — so the SAME collection
|
|
206
|
-
* can fan in many documents (e.g. many users' `profile`) in a single request.
|
|
207
|
-
* The server auto-fills the `{identity}` param from the authenticated caller for
|
|
208
|
-
* any set that omits it, so a self-doc collection needs no params. Returns a map
|
|
209
|
-
* of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
|
|
210
|
-
* in request order. Honors the configured namespace.
|
|
211
|
-
*
|
|
212
|
-
* For the common "many docs of one collection" case prefer {@link batchPullMany}.
|
|
213
|
-
*
|
|
214
|
-
* Note: not append/checkpoint-aware — for incremental append-only reads use
|
|
215
|
-
* `pull(path, { since })` (or `AppendLogCursor`) per collection.
|
|
216
|
-
*/
|
|
217
|
-
async batchPull(collections, opts = {}) {
|
|
218
|
-
const search = new URLSearchParams();
|
|
219
|
-
search.set("collections", collections.join(","));
|
|
220
|
-
if (opts.params && Object.keys(opts.params).length > 0) {
|
|
221
|
-
search.set("params", JSON.stringify(opts.params));
|
|
222
|
-
}
|
|
223
|
-
const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
|
|
224
|
-
const url = `${this.baseUrl}${pathAndQuery}`;
|
|
225
|
-
const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, undefined);
|
|
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
|
+
: {};
|
|
226
27
|
const res = await this.fetch(url, {
|
|
227
28
|
method: "GET",
|
|
228
|
-
headers: {
|
|
29
|
+
headers: { Accept: "application/json", ...authHeaders },
|
|
229
30
|
});
|
|
230
31
|
if (!res.ok) {
|
|
231
32
|
throw new StarfishHttpError(res.status, await res.text());
|
|
232
33
|
}
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Convenience over {@link batchPull} for reading MANY documents of ONE
|
|
237
|
-
* collection in a single round-trip: pass the per-document param-sets and get
|
|
238
|
-
* back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
|
|
239
|
-
* entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
|
|
240
|
-
* issues no request and returns `[]`.
|
|
241
|
-
*/
|
|
242
|
-
async batchPullMany(collection, paramsList) {
|
|
243
|
-
if (paramsList.length === 0)
|
|
244
|
-
return [];
|
|
245
|
-
const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
|
|
246
|
-
return res.collections[collection] ?? [];
|
|
34
|
+
return res.json();
|
|
247
35
|
}
|
|
248
36
|
/**
|
|
249
37
|
* Push synced data to the server.
|
|
250
38
|
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
251
39
|
* @param data - The full document data to push
|
|
252
40
|
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
253
|
-
*
|
|
254
|
-
* v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
|
|
255
|
-
* (produced by `SyncManager` when a `signer` is configured) and sent as
|
|
256
|
-
* top-level body siblings of `data`, where the server verifies it.
|
|
41
|
+
* @param authorSignature - Optional author signature for provenance
|
|
257
42
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
258
43
|
*/
|
|
259
|
-
async push(path, data, baseHash,
|
|
44
|
+
async push(path, data, baseHash, authorSignature) {
|
|
260
45
|
const body = JSON.stringify({
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
...(
|
|
264
|
-
[AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
|
|
265
|
-
[AUTHOR_SIGNATURE_FIELD]: author.authorSignature,
|
|
266
|
-
}),
|
|
46
|
+
data,
|
|
47
|
+
baseHash,
|
|
48
|
+
...(authorSignature && { authorSignature }),
|
|
267
49
|
});
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
50
|
+
const authHeaders = this.auth
|
|
51
|
+
? await this.auth({ method: "POST", path, body })
|
|
52
|
+
: {};
|
|
53
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
271
54
|
method: "POST",
|
|
272
55
|
headers: {
|
|
273
|
-
|
|
274
|
-
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
Accept: "application/json",
|
|
275
58
|
...authHeaders,
|
|
276
59
|
},
|
|
277
60
|
body,
|
|
@@ -284,84 +67,23 @@ export class StarfishClient {
|
|
|
284
67
|
}
|
|
285
68
|
return res.json();
|
|
286
69
|
}
|
|
287
|
-
/**
|
|
288
|
-
* Append an element to an appendOnly (`by_timestamp`) collection.
|
|
289
|
-
*
|
|
290
|
-
* Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
|
|
291
|
-
* authorized append is always accepted. Each element is stored server-side as
|
|
292
|
-
* `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
|
|
293
|
-
*
|
|
294
|
-
* @param path - the push endpoint (e.g. "/push/events")
|
|
295
|
-
* @param data - the element payload. For a `delegated` collection, encrypt it
|
|
296
|
-
* first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
|
|
297
|
-
* server stores it opaquely and never reads it.
|
|
298
|
-
* @param opts.ts - optional client-supplied element timestamp (ms). Must be a
|
|
299
|
-
* non-negative integer strictly greater than the latest stored element's ts
|
|
300
|
-
* (else the server responds 409). Omit to let the server assign one.
|
|
301
|
-
* @throws {StarfishHttpError} on a non-2xx response — e.g. 409
|
|
302
|
-
* `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
|
|
303
|
-
* `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
|
|
304
|
-
* cap is reached (partition by a path parameter for higher volume).
|
|
305
|
-
*/
|
|
306
|
-
async append(path, data, opts = {}) {
|
|
307
|
-
const sendPath = this.applyNamespace(path);
|
|
308
|
-
const bodyObj = { [DATA_FIELD]: data };
|
|
309
|
-
if (opts.ts !== undefined)
|
|
310
|
-
bodyObj[TS_FIELD] = opts.ts;
|
|
311
|
-
// Author proof. Fetch the cap ONCE and reuse it for both the author
|
|
312
|
-
// signature (over the element `data`) and the request signature (over the
|
|
313
|
-
// final body) — see {@link capRequestHeaders}. The author fields are signed
|
|
314
|
-
// with the same key that authenticates the request, so a collection with
|
|
315
|
-
// `requireAuthorSignature` (the default) binds the stored element to its
|
|
316
|
-
// writer. Without a cap provider the append is sent unsigned and such a
|
|
317
|
-
// collection rejects it.
|
|
318
|
-
const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
|
|
319
|
-
if (capCtx) {
|
|
320
|
-
const authorKey = this.appendAuthorKey(capCtx);
|
|
321
|
-
if (authorKey) {
|
|
322
|
-
// The signature binds the author to BOTH the element data AND the
|
|
323
|
-
// document it is written to (the storage path = `path` minus the
|
|
324
|
-
// `/push/` action prefix; the namespace lives only in the URL).
|
|
325
|
-
const documentKey = stripPushPrefix(path);
|
|
326
|
-
const { authorPubkey, authorSignature } = signAppendAuthor(documentKey, data, authorKey.authorPubHex, capCtx.devEdPrivHex);
|
|
327
|
-
bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
|
|
328
|
-
bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
const body = JSON.stringify(bodyObj);
|
|
332
|
-
const authHeaders = capCtx
|
|
333
|
-
? await this.capRequestHeaders(capCtx, "POST", sendPath, body)
|
|
334
|
-
: {};
|
|
335
|
-
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
336
|
-
method: "POST",
|
|
337
|
-
headers: {
|
|
338
|
-
[HEADER_CONTENT_TYPE]: "application/json",
|
|
339
|
-
[HEADER_ACCEPT]: "application/json",
|
|
340
|
-
...authHeaders,
|
|
341
|
-
},
|
|
342
|
-
body,
|
|
343
|
-
});
|
|
344
|
-
if (!res.ok) {
|
|
345
|
-
throw new StarfishHttpError(res.status, await res.text());
|
|
346
|
-
}
|
|
347
|
-
return res.json();
|
|
348
|
-
}
|
|
349
70
|
/**
|
|
350
71
|
* Pull binary data from a blob collection.
|
|
351
72
|
* Returns raw bytes with the content hash from the ETag header.
|
|
352
73
|
*/
|
|
353
74
|
async pullBlob(path) {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
75
|
+
const authHeaders = this.auth
|
|
76
|
+
? await this.auth({ method: "GET", path, body: null })
|
|
77
|
+
: {};
|
|
78
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
357
79
|
method: "GET",
|
|
358
|
-
headers: {
|
|
80
|
+
headers: { Accept: "*/*", ...authHeaders },
|
|
359
81
|
});
|
|
360
82
|
if (!res.ok) {
|
|
361
83
|
throw new StarfishHttpError(res.status, await res.text());
|
|
362
84
|
}
|
|
363
85
|
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
364
|
-
const contentType = res.headers.get(
|
|
86
|
+
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
365
87
|
const data = await res.arrayBuffer();
|
|
366
88
|
return { data, hash: etag, contentType };
|
|
367
89
|
}
|
|
@@ -370,15 +92,14 @@ export class StarfishClient {
|
|
|
370
92
|
* Binary collections use last-write-wins (no conflict detection).
|
|
371
93
|
*/
|
|
372
94
|
async pushBlob(path, data, contentType) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
|
|
95
|
+
const authHeaders = this.auth
|
|
96
|
+
? await this.auth({ method: "POST", path, body: null })
|
|
97
|
+
: {};
|
|
98
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
378
99
|
method: "POST",
|
|
379
100
|
headers: {
|
|
380
|
-
|
|
381
|
-
|
|
101
|
+
"Content-Type": contentType,
|
|
102
|
+
Accept: "application/json",
|
|
382
103
|
...authHeaders,
|
|
383
104
|
},
|
|
384
105
|
body: data,
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
}
|