@estiva-app/protocol 0.1.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/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/bridge.d.ts +91 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +138 -0
- package/dist/bridge.js.map +1 -0
- package/dist/events.d.ts +432 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +616 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/live.d.ts +205 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +398 -0
- package/dist/live.js.map +1 -0
- package/dist/nip19.d.ts +98 -0
- package/dist/nip19.d.ts.map +1 -0
- package/dist/nip19.js +320 -0
- package/dist/nip19.js.map +1 -0
- package/dist/nip98.d.ts +61 -0
- package/dist/nip98.d.ts.map +1 -0
- package/dist/nip98.js +134 -0
- package/dist/nip98.js.map +1 -0
- package/dist/sign.d.ts +67 -0
- package/dist/sign.d.ts.map +1 -0
- package/dist/sign.js +58 -0
- package/dist/sign.js.map +1 -0
- package/dist/subscriptions.d.ts +120 -0
- package/dist/subscriptions.d.ts.map +1 -0
- package/dist/subscriptions.js +68 -0
- package/dist/subscriptions.js.map +1 -0
- package/package.json +59 -0
- package/src/bridge.ts +198 -0
- package/src/events.ts +821 -0
- package/src/index.ts +159 -0
- package/src/live.ts +536 -0
- package/src/nip19.ts +354 -0
- package/src/nip98.ts +164 -0
- package/src/sign.ts +113 -0
- package/src/subscriptions.ts +200 -0
package/dist/sign.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BIP-340 Schnorr signing, and the signer seam every publish path goes through.
|
|
3
|
+
*
|
|
4
|
+
* Signing needs entropy (`@noble/curves` draws auxiliary randomness per
|
|
5
|
+
* signature), so `signEvent` only works where a CSPRNG is available — a browser,
|
|
6
|
+
* Node, Convex's Node runtime. That is a *runtime* requirement rather than a type
|
|
7
|
+
* one: nothing here reads a global, and importing this module is safe anywhere.
|
|
8
|
+
*
|
|
9
|
+
* ## What is deliberately not here
|
|
10
|
+
*
|
|
11
|
+
* `estivaIdSigner` — the signer that posts to Estiva ID's `/sign` and holds no
|
|
12
|
+
* key at all — is **identity**, and belongs to `@estiva-app/identity` (SHA-4).
|
|
13
|
+
* This package defines the {@link Signer} interface it will implement, because
|
|
14
|
+
* the relay clients need something to sign with and the interface is the seam
|
|
15
|
+
* between "the bytes" and "who is allowed to sign them". Keeping the interface
|
|
16
|
+
* here and the implementations there is what stops `protocol` growing a
|
|
17
|
+
* dependency on an identity service.
|
|
18
|
+
*/
|
|
19
|
+
import { schnorr } from '@noble/curves/secp256k1';
|
|
20
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
|
21
|
+
import { computeEventId } from './events.js';
|
|
22
|
+
/** Derive the 64-char hex x-only public key for a secret key. */
|
|
23
|
+
export function publicKeyFromSecret(secretKeyHex) {
|
|
24
|
+
return bytesToHex(schnorr.getPublicKey(hexToBytes(secretKeyHex)));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Compute the event id and sign it, producing a relay-submittable event.
|
|
28
|
+
*
|
|
29
|
+
* Throws if `unsigned.pubkey` does not match the secret key — a mismatch would
|
|
30
|
+
* produce an event the relay silently rejects as an invalid signature, which is
|
|
31
|
+
* painful to debug from the other side.
|
|
32
|
+
*/
|
|
33
|
+
export function signEvent(unsigned, secretKeyHex) {
|
|
34
|
+
const derived = publicKeyFromSecret(secretKeyHex);
|
|
35
|
+
if (derived !== unsigned.pubkey) {
|
|
36
|
+
throw new Error(`pubkey mismatch: event declares ${unsigned.pubkey.slice(0, 16)}… but the secret key derives ${derived.slice(0, 16)}…`);
|
|
37
|
+
}
|
|
38
|
+
const id = computeEventId(unsigned);
|
|
39
|
+
const sig = bytesToHex(schnorr.sign(id, hexToBytes(secretKeyHex)));
|
|
40
|
+
return { ...unsigned, id, sig };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A signer holding a raw secret key.
|
|
44
|
+
*
|
|
45
|
+
* The honest name for what a script has always done. Used directly by scripts,
|
|
46
|
+
* which have no `localStorage`, and underneath an app's per-browser identity.
|
|
47
|
+
*/
|
|
48
|
+
export function secretKeySigner(secretKeyHex, kind = 'local') {
|
|
49
|
+
const pubkey = publicKeyFromSecret(secretKeyHex);
|
|
50
|
+
return {
|
|
51
|
+
pubkey,
|
|
52
|
+
kind,
|
|
53
|
+
async sign(unsigned) {
|
|
54
|
+
return signEvent({ ...unsigned, pubkey }, secretKeyHex);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=sign.js.map
|
package/dist/sign.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sign.js","sourceRoot":"","sources":["../src/sign.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AACjD,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAC5D,OAAO,EAAE,cAAc,EAAwC,MAAM,aAAa,CAAA;AAElF,iEAAiE;AACjE,MAAM,UAAU,mBAAmB,CAAC,YAAoB;IACtD,OAAO,UAAU,CAAC,OAAO,CAAC,YAAY,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;AACnE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,SAAS,CAAC,QAAuB,EAAE,YAAoB;IACrE,MAAM,OAAO,GAAG,mBAAmB,CAAC,YAAY,CAAC,CAAA;IACjD,IAAI,OAAO,KAAK,QAAQ,CAAC,MAAM,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CACb,mCAAmC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,gCAAgC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CACvH,CAAA;IACH,CAAC;IACD,MAAM,EAAE,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAA;IACnC,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;IAClE,OAAO,EAAE,GAAG,QAAQ,EAAE,EAAE,EAAE,GAAG,EAAE,CAAA;AACjC,CAAC;AAqDD;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,YAAoB,EAAE,OAAmB,OAAO;IAC9E,MAAM,MAAM,GAAG,mBAAmB,CAAC,YAAY,CAAC,CAAA;IAChD,OAAO;QACL,MAAM;QACN,IAAI;QACJ,KAAK,CAAC,IAAI,CAAC,QAAQ;YACjB,OAAO,SAAS,CAAC,EAAE,GAAG,QAAQ,EAAE,MAAM,EAAE,EAAE,YAAY,CAAC,CAAA;QACzD,CAAC;KACF,CAAA;AACH,CAAC"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One REQ per channel, refcounted (PEE-6).
|
|
3
|
+
*
|
|
4
|
+
* Two components can be looking at the same channel — the conversation and the
|
|
5
|
+
* sidebar — and the last one to unmount is the one that should close the REQ.
|
|
6
|
+
* This sits over `liveRelay` and owns exactly that: one relay subscription per
|
|
7
|
+
* channel uuid, shared by every consumer, closed when the last releases.
|
|
8
|
+
*
|
|
9
|
+
* Reconnection is deliberately **not** handled here. `createLiveRelay` re-issues
|
|
10
|
+
* every registered subscription after it re-authenticates, so a channel with a
|
|
11
|
+
* live refcount comes back on its own. Duplicating that logic would give two
|
|
12
|
+
* places to get it wrong.
|
|
13
|
+
*
|
|
14
|
+
* **Built inside Peek** (PEE-6) because Gate 2 had not happened when it was due.
|
|
15
|
+
* SHA-3 is the ticket that owed the move.
|
|
16
|
+
*
|
|
17
|
+
* ## The trap: one subscription per channel, always
|
|
18
|
+
*
|
|
19
|
+
* **Never build one subscription covering several channels.** Depending on the
|
|
20
|
+
* filter it either fails loudly or, worse, returns correct history and then
|
|
21
|
+
* receives **zero live events** — EOSE right, live empty, nothing to
|
|
22
|
+
* distinguish it from working until somebody notices nothing ever arrives.
|
|
23
|
+
*
|
|
24
|
+
* Verified in Buzz rather than taken on trust, because the whole point is that
|
|
25
|
+
* it is invisible:
|
|
26
|
+
*
|
|
27
|
+
* 1. `extract_channel_id_from_filters` (`handlers/req.rs`) returns `None` the
|
|
28
|
+
* moment two distinct `#h` values appear, or any filter lacks `#h`.
|
|
29
|
+
* 2. With `channel_id: None` the subscription registers in the **global**
|
|
30
|
+
* indexes (`subscription.rs`).
|
|
31
|
+
* 3. `fan_out_scoped` handles a channel-scoped event by consulting only
|
|
32
|
+
* `channel_kind_index` and `channel_wildcard_index`. A global subscription
|
|
33
|
+
* is in neither.
|
|
34
|
+
* 4. The file states it outright: *"Global subscriptions (channel_id = None)
|
|
35
|
+
* do NOT receive channel-scoped events."*
|
|
36
|
+
*
|
|
37
|
+
* Historical delivery at REQ time takes a different path (`per_filter_channel`)
|
|
38
|
+
* which handles multi-`#h` correctly. That asymmetry is the whole illusion.
|
|
39
|
+
*
|
|
40
|
+
* **Which of the two failures you get depends on whether the filter names
|
|
41
|
+
* kinds**, and this was measured against production rather than reasoned about.
|
|
42
|
+
* A global subscription must clear `p_gated_filters_authorized`
|
|
43
|
+
* (`handlers/req.rs`), whose first test is:
|
|
44
|
+
*
|
|
45
|
+
* let can_match_p_gated = filter.kinds.as_ref().is_none_or(|ks| …);
|
|
46
|
+
* if !can_match_p_gated { return true; }
|
|
47
|
+
*
|
|
48
|
+
* So a **kindless** multi-`#h` filter *could* match a p-gated kind, has no
|
|
49
|
+
* `#p`, and is refused outright — a live probe against
|
|
50
|
+
* `wss://estiva.estiva.app` got `CLOSED … "restricted: p-gated events require
|
|
51
|
+
* #p matching your pubkey"` immediately. But a filter naming only ordinary
|
|
52
|
+
* kinds — `{"#h":[a,b],"kinds":[9]}` — returns early as authorized, registers
|
|
53
|
+
* globally, and dies **silently**.
|
|
54
|
+
*
|
|
55
|
+
* That is the dangerous one, and it is the shape somebody optimising "one
|
|
56
|
+
* subscription for messages across all my channels" would naturally write. The
|
|
57
|
+
* ticket describes this variant; the loud one is a newer gate sitting in front
|
|
58
|
+
* of it.
|
|
59
|
+
*
|
|
60
|
+
* **There is a second entrance to the same trap, and it is the likelier one.**
|
|
61
|
+
* `extract_channel_id_from_filters` only counts an `#h` value it can
|
|
62
|
+
* `parse::<uuid::Uuid>()`; anything else leaves `filter_has_channel` false and
|
|
63
|
+
* falls through to the same global registration. So passing a **topic id**
|
|
64
|
+
* where a channel uuid belongs produces the same broken subscription — and
|
|
65
|
+
* Peek's topic ids are Convex ids, which are not uuids. Because this module
|
|
66
|
+
* always builds a kindless filter, that lands on the loud arm above rather than
|
|
67
|
+
* the silent one; it is still a subscription that never delivers, and it still
|
|
68
|
+
* fails asynchronously as a `CLOSED` frame the app would have to interpret.
|
|
69
|
+
* Throwing at the call site names the cause instead. RFC 0.3's note on this ticket
|
|
70
|
+
* asks specifically that a topic id never become the subscription key; that is
|
|
71
|
+
* why {@link createChannelSubscriptions} validates the shape and throws rather
|
|
72
|
+
* than letting a bad key reach the relay. In Peek `topics.channelUuid` is also
|
|
73
|
+
* `v.optional`, so "absent on older topics" is a real case, not a theoretical
|
|
74
|
+
* one, and it must not arrive here as `undefined`.
|
|
75
|
+
*
|
|
76
|
+
* ## One kindless filter is enough
|
|
77
|
+
*
|
|
78
|
+
* `{"#h":[uuid]}` with no `kinds` registers in the channel **wildcard** index
|
|
79
|
+
* and therefore receives every kind in the channel. Reactions (kind:7) and
|
|
80
|
+
* deletions (kind:5) carry no `h` tag of their own, but `filters_match`
|
|
81
|
+
* (`buzz-core/src/filter.rs`) falls back to `StoredEvent.channel_id` for `#h`
|
|
82
|
+
* when an event has no `h` tags at all — the channel is derived from the target
|
|
83
|
+
* at ingest. So messages, reactions, deletions and assertions all arrive on this
|
|
84
|
+
* one subscription.
|
|
85
|
+
*
|
|
86
|
+
* That is strictly better than the HTTP path it replaces, which needs four
|
|
87
|
+
* sequential round trips and caps reactions to the newest 100 messages per
|
|
88
|
+
* topic. The cap is deliberately not ported.
|
|
89
|
+
*
|
|
90
|
+
* `kinds: []` would be worse than useless — Buzz indexes such a subscription
|
|
91
|
+
* *nowhere* and it silently receives nothing — which is another reason this
|
|
92
|
+
* builds the filter itself rather than accepting one.
|
|
93
|
+
*/
|
|
94
|
+
import type { SignedEvent } from './events.js';
|
|
95
|
+
import type { LiveRelay } from './live.js';
|
|
96
|
+
export type ChannelEventHandler = (event: SignedEvent) => void;
|
|
97
|
+
export interface ChannelSubscription {
|
|
98
|
+
/** Idempotent. Closes the REQ only when the last consumer releases. */
|
|
99
|
+
release(): void;
|
|
100
|
+
}
|
|
101
|
+
export interface ChannelSubscriptions {
|
|
102
|
+
/**
|
|
103
|
+
* Watch one channel. Safe to call many times for the same channel — the
|
|
104
|
+
* relay sees one REQ, and every consumer sees every event.
|
|
105
|
+
*
|
|
106
|
+
* @throws if `channelUuid` is not a uuid. See the trap above: a topic id here
|
|
107
|
+
* would be accepted by the relay and then silently deliver nothing.
|
|
108
|
+
*/
|
|
109
|
+
subscribe(channelUuid: string, onEvent: ChannelEventHandler): ChannelSubscription;
|
|
110
|
+
/** Channels with at least one live consumer. Test and diagnostic seam. */
|
|
111
|
+
activeChannels(): string[];
|
|
112
|
+
/** How many consumers hold `channelUuid`. Test and diagnostic seam. */
|
|
113
|
+
subscriberCount(channelUuid: string): number;
|
|
114
|
+
/** Release everything. Does not close the underlying relay connection. */
|
|
115
|
+
close(): void;
|
|
116
|
+
}
|
|
117
|
+
export declare function createChannelSubscriptions(relay: Pick<LiveRelay, 'subscribe'>, options?: {
|
|
118
|
+
onListenerError?: (error: unknown) => void;
|
|
119
|
+
}): ChannelSubscriptions;
|
|
120
|
+
//# sourceMappingURL=subscriptions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscriptions.d.ts","sourceRoot":"","sources":["../src/subscriptions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4FG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAgB,MAAM,WAAW,CAAA;AAKxD,MAAM,MAAM,mBAAmB,GAAG,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;AAE9D,MAAM,WAAW,mBAAmB;IAClC,uEAAuE;IACvE,OAAO,IAAI,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,mBAAmB,GAAG,mBAAmB,CAAA;IACjF,0EAA0E;IAC1E,cAAc,IAAI,MAAM,EAAE,CAAA;IAC1B,uEAAuE;IACvE,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAA;IAC5C,0EAA0E;IAC1E,KAAK,IAAI,IAAI,CAAA;CACd;AAOD,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC,EACnC,OAAO,GAAE;IAAE,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CAAO,GAC3D,oBAAoB,CAoEtB"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Canonical v4-shaped uuid, as `crypto.randomUUID()` produces. */
|
|
2
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
export function createChannelSubscriptions(relay, options = {}) {
|
|
4
|
+
const channels = new Map();
|
|
5
|
+
function dispatch(channelUuid, event) {
|
|
6
|
+
const entry = channels.get(channelUuid);
|
|
7
|
+
if (!entry)
|
|
8
|
+
return;
|
|
9
|
+
// A copy, because a listener is allowed to release during dispatch — and
|
|
10
|
+
// one that throws must not stop the others from being told. A single
|
|
11
|
+
// component's bug should not silently stop the whole channel updating.
|
|
12
|
+
for (const listener of [...entry.listeners]) {
|
|
13
|
+
try {
|
|
14
|
+
listener(event);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
options.onListenerError?.(error);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
subscribe(channelUuid, onEvent) {
|
|
23
|
+
if (!UUID.test(channelUuid)) {
|
|
24
|
+
throw new Error(`channelSubscriptions: "${channelUuid}" is not a channel uuid. ` +
|
|
25
|
+
'Buzz can only scope a subscription by an #h it can parse as a uuid; ' +
|
|
26
|
+
'anything else registers globally and then receives no channel events at all. ' +
|
|
27
|
+
"Pass the channel's uuid, never an application id for the thing " +
|
|
28
|
+
'rendered in it — Peek\'s topic ids are Convex ids, which are not uuids.');
|
|
29
|
+
}
|
|
30
|
+
let entry = channels.get(channelUuid);
|
|
31
|
+
if (!entry) {
|
|
32
|
+
const listeners = new Set();
|
|
33
|
+
// One channel, one filter, no `kinds` — see the header. Built here
|
|
34
|
+
// rather than accepted from the caller so neither half of the trap is
|
|
35
|
+
// reachable through this API.
|
|
36
|
+
const relaySub = relay.subscribe([{ '#h': [channelUuid] }], (event) => dispatch(channelUuid, event));
|
|
37
|
+
entry = { relaySub, listeners };
|
|
38
|
+
channels.set(channelUuid, entry);
|
|
39
|
+
}
|
|
40
|
+
entry.listeners.add(onEvent);
|
|
41
|
+
let released = false;
|
|
42
|
+
return {
|
|
43
|
+
release() {
|
|
44
|
+
if (released)
|
|
45
|
+
return;
|
|
46
|
+
released = true;
|
|
47
|
+
const current = channels.get(channelUuid);
|
|
48
|
+
if (!current)
|
|
49
|
+
return;
|
|
50
|
+
current.listeners.delete(onEvent);
|
|
51
|
+
if (current.listeners.size > 0)
|
|
52
|
+
return;
|
|
53
|
+
// Last one out closes the REQ.
|
|
54
|
+
channels.delete(channelUuid);
|
|
55
|
+
current.relaySub.close();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
activeChannels: () => [...channels.keys()],
|
|
60
|
+
subscriberCount: (channelUuid) => channels.get(channelUuid)?.listeners.size ?? 0,
|
|
61
|
+
close() {
|
|
62
|
+
for (const entry of channels.values())
|
|
63
|
+
entry.relaySub.close();
|
|
64
|
+
channels.clear();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=subscriptions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscriptions.js","sourceRoot":"","sources":["../src/subscriptions.ts"],"names":[],"mappings":"AAgGA,mEAAmE;AACnE,MAAM,IAAI,GAAG,iEAAiE,CAAA;AA+B9E,MAAM,UAAU,0BAA0B,CACxC,KAAmC,EACnC,UAA0D,EAAE;IAE5D,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAwB,CAAA;IAEhD,SAAS,QAAQ,CAAC,WAAmB,EAAE,KAAkB;QACvD,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;QACvC,IAAI,CAAC,KAAK;YAAE,OAAM;QAClB,yEAAyE;QACzE,qEAAqE;QACrE,uEAAuE;QACvE,KAAK,MAAM,QAAQ,IAAI,CAAC,GAAG,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YAC5C,IAAI,CAAC;gBACH,QAAQ,CAAC,KAAK,CAAC,CAAA;YACjB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,eAAe,EAAE,CAAC,KAAK,CAAC,CAAA;YAClC,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO;QACL,SAAS,CAAC,WAAW,EAAE,OAAO;YAC5B,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;gBAC5B,MAAM,IAAI,KAAK,CACb,0BAA0B,WAAW,2BAA2B;oBAC9D,sEAAsE;oBACtE,+EAA+E;oBAC/E,iEAAiE;oBACjE,yEAAyE,CAC5E,CAAA;YACH,CAAC;YAED,IAAI,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;YACrC,IAAI,CAAC,KAAK,EAAE,CAAC;gBACX,MAAM,SAAS,GAAG,IAAI,GAAG,EAAuB,CAAA;gBAChD,mEAAmE;gBACnE,sEAAsE;gBACtE,8BAA8B;gBAC9B,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,CAAC,EAAE,CAAC,EAAE,CAAC,KAAK,EAAE,EAAE,CACpE,QAAQ,CAAC,WAAW,EAAE,KAAK,CAAC,CAC7B,CAAA;gBACD,KAAK,GAAG,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAA;gBAC/B,QAAQ,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,CAAC,CAAA;YAClC,CAAC;YACD,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;YAE5B,IAAI,QAAQ,GAAG,KAAK,CAAA;YACpB,OAAO;gBACL,OAAO;oBACL,IAAI,QAAQ;wBAAE,OAAM;oBACpB,QAAQ,GAAG,IAAI,CAAA;oBACf,MAAM,OAAO,GAAG,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;oBACzC,IAAI,CAAC,OAAO;wBAAE,OAAM;oBACpB,OAAO,CAAC,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;oBACjC,IAAI,OAAO,CAAC,SAAS,CAAC,IAAI,GAAG,CAAC;wBAAE,OAAM;oBACtC,+BAA+B;oBAC/B,QAAQ,CAAC,MAAM,CAAC,WAAW,CAAC,CAAA;oBAC5B,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAA;gBAC1B,CAAC;aACF,CAAA;QACH,CAAC;QAED,cAAc,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC;QAC1C,eAAe,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,SAAS,CAAC,IAAI,IAAI,CAAC;QAEhF,KAAK;YACH,KAAK,MAAM,KAAK,IAAI,QAAQ,CAAC,MAAM,EAAE;gBAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAA;YAC7D,QAAQ,CAAC,KAAK,EAAE,CAAA;QAClB,CAAC;KACF,CAAA;AACH,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@estiva-app/protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Estiva wire format: Buzz-shaped Nostr event builders, NIP-01 ids, NIP-19 naddr, NIP-98 HTTP auth, and the relay clients. Speaks the protocol; interprets nothing.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/estiva-app/estiva-foundation.git",
|
|
33
|
+
"directory": "packages/protocol"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"nostr",
|
|
37
|
+
"nip-01",
|
|
38
|
+
"nip-19",
|
|
39
|
+
"nip-98",
|
|
40
|
+
"buzz",
|
|
41
|
+
"estiva"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"test": "npm run build && node --test test/*.test.mjs test/*.test.ts",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
47
|
+
"prepublishOnly": "npm run build",
|
|
48
|
+
"verify:live": "node scripts/verify-live.mjs"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@noble/curves": "^1.9.2",
|
|
52
|
+
"@noble/hashes": "^1.8.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"nostr-tools": "^2.25.0",
|
|
56
|
+
"typescript": "~5.9.3",
|
|
57
|
+
"@types/node": "^24.13.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buzz's HTTP bridge — `POST /events`, `POST /query` — with NIP-98 auth.
|
|
3
|
+
*
|
|
4
|
+
* Behaviours here were established against a running relay, not inferred. Three
|
|
5
|
+
* that bite, and the first is the one that makes a green deploy lie:
|
|
6
|
+
*
|
|
7
|
+
* - **HTTP 200 does not mean accepted.** A duplicate channel create returns
|
|
8
|
+
* `200 {"accepted":false,"message":"duplicate: channel already exists"}`.
|
|
9
|
+
* The `accepted` field is authoritative and the status code alone would read
|
|
10
|
+
* rejections as successes. This is SPEC §5's C3 conformance check.
|
|
11
|
+
* - **`/query` takes a bare ARRAY of filters**, not a single filter object.
|
|
12
|
+
* Sending one object gets `invalid type: map, expected a sequence`.
|
|
13
|
+
* - **A non-member read is answered `403 relay_membership_required`.** There is
|
|
14
|
+
* no backend identity that can see anything, which is why reads are
|
|
15
|
+
* viewer-driven.
|
|
16
|
+
*
|
|
17
|
+
* ## The parsers are exported separately, and that is the point
|
|
18
|
+
*
|
|
19
|
+
* {@link parsePublishResponse} and {@link parseQueryResponse} existed twice
|
|
20
|
+
* before SHA-3 — once in Peek's browser bridge, once in Ship's `Relay` — with
|
|
21
|
+
* the same `accepted` handling written out both times. They are exported on
|
|
22
|
+
* their own so an app with its own transport (Peek signs its auth event through
|
|
23
|
+
* Estiva ID, which is `@estiva-app/identity`'s job, not this package's) still
|
|
24
|
+
* shares the *interpretation of the answer*. Getting the bytes right and then
|
|
25
|
+
* reading `200` as success is a way to fail that no test notices.
|
|
26
|
+
*/
|
|
27
|
+
import { authorizationHeader } from './nip98.js'
|
|
28
|
+
import type { SignedEvent } from './events.js'
|
|
29
|
+
import type { Signer } from './sign.js'
|
|
30
|
+
|
|
31
|
+
export interface PublishResult {
|
|
32
|
+
ok: boolean
|
|
33
|
+
eventId?: string
|
|
34
|
+
reason?: string
|
|
35
|
+
/** The relay already had this state — not a failure. */
|
|
36
|
+
duplicate?: boolean
|
|
37
|
+
/** The HTTP status, for a caller that wants to distinguish 403 from 500. */
|
|
38
|
+
httpStatus?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The relay's answer to `POST /events`, interpreted.
|
|
43
|
+
*
|
|
44
|
+
* `duplicate` is reported as `ok`: "already exists" is the desired end state, so
|
|
45
|
+
* a caller creating a channel that is already there has succeeded. Every other
|
|
46
|
+
* `accepted: false` is a failure the caller must surface — SPEC §9's C9, which
|
|
47
|
+
* is not decoration: once no app can sign locally, an identity-service outage
|
|
48
|
+
* looks exactly like nothing happening.
|
|
49
|
+
*/
|
|
50
|
+
export function parsePublishResponse(status: number, text: string): PublishResult {
|
|
51
|
+
let parsed: { accepted?: boolean; event_id?: string; message?: string; error?: string }
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(text)
|
|
54
|
+
} catch {
|
|
55
|
+
return { ok: false, reason: `unparseable response: ${text.slice(0, 200)}`, httpStatus: status }
|
|
56
|
+
}
|
|
57
|
+
if (status < 200 || status >= 300) {
|
|
58
|
+
return { ok: false, reason: parsed.error ?? text.slice(0, 200), httpStatus: status }
|
|
59
|
+
}
|
|
60
|
+
if (parsed.accepted === false) {
|
|
61
|
+
const duplicate = (parsed.message ?? '').startsWith('duplicate:')
|
|
62
|
+
return { ok: duplicate, duplicate, eventId: parsed.event_id, reason: parsed.message, httpStatus: status }
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, eventId: parsed.event_id, httpStatus: status }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface QueryResult {
|
|
68
|
+
ok: boolean
|
|
69
|
+
events: SignedEvent[]
|
|
70
|
+
reason?: string
|
|
71
|
+
httpStatus?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The relay's answer to `POST /query`, interpreted. */
|
|
75
|
+
export function parseQueryResponse(status: number, text: string): QueryResult {
|
|
76
|
+
if (status < 200 || status >= 300) {
|
|
77
|
+
let reason = text.slice(0, 200)
|
|
78
|
+
try {
|
|
79
|
+
reason = JSON.parse(text).error ?? reason
|
|
80
|
+
} catch {
|
|
81
|
+
// keep the raw text
|
|
82
|
+
}
|
|
83
|
+
return { ok: false, events: [], reason, httpStatus: status }
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(text)
|
|
87
|
+
return { ok: true, events: Array.isArray(parsed) ? parsed : [], httpStatus: status }
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, events: [], reason: `unparseable response: ${text.slice(0, 200)}`, httpStatus: status }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Just enough of `fetch` to post a body and read the answer.
|
|
95
|
+
*
|
|
96
|
+
* A parameter rather than an ambient global, per ADR 0002 §4a — `fetch` is in
|
|
97
|
+
* neither `lib.es2022` nor a package that may assume `lib.dom`. The default
|
|
98
|
+
* below reads the runtime's own, so no consumer has to pass one.
|
|
99
|
+
*/
|
|
100
|
+
export type FetchLike = (
|
|
101
|
+
url: string,
|
|
102
|
+
init: { method: string; headers: Record<string, string>; body: string },
|
|
103
|
+
) => Promise<{ status: number; text(): Promise<string> }>
|
|
104
|
+
|
|
105
|
+
/** See {@link FetchLike}. Read inside a method, so importing touches no global. */
|
|
106
|
+
declare const fetch: FetchLike
|
|
107
|
+
|
|
108
|
+
/** Extra headers to send with every relay request, resolved per request. */
|
|
109
|
+
export type RelayHeaders = () => Record<string, string> | Promise<Record<string, string>>
|
|
110
|
+
|
|
111
|
+
export interface RelayOptions {
|
|
112
|
+
/**
|
|
113
|
+
* ## Why `headers` is a callback
|
|
114
|
+
*
|
|
115
|
+
* Buzz reads more than NIP-98 off a request. A NIP-OA owner attestation travels
|
|
116
|
+
* in `x-auth-tag`, and on a closed relay it is what admits an agent whose
|
|
117
|
+
* *owner* is a member — so it is required on **every** call, `/query` included,
|
|
118
|
+
* not just on writes.
|
|
119
|
+
*
|
|
120
|
+
* A callback rather than a fixed object because what it carries can expire: an
|
|
121
|
+
* agent's attestation is minted with its token and replaced when that token is
|
|
122
|
+
* renewed. A snapshot taken at construction would work for one TTL and then
|
|
123
|
+
* fail as a `403 relay_membership_required` that looks nothing like an expiry.
|
|
124
|
+
*
|
|
125
|
+
* Empty by default, so an app with no such credential is unaffected.
|
|
126
|
+
*/
|
|
127
|
+
headers?: RelayHeaders
|
|
128
|
+
/** Override the transport. Defaults to the runtime's `fetch`. */
|
|
129
|
+
fetch?: FetchLike
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A relay client that signs its own NIP-98 auth.
|
|
134
|
+
*
|
|
135
|
+
* Takes a {@link Signer} rather than a key or a token, which is what lets the
|
|
136
|
+
* same class serve a script holding a secret key and a browser signing through a
|
|
137
|
+
* remote service.
|
|
138
|
+
*/
|
|
139
|
+
export class Relay {
|
|
140
|
+
private readonly url: string
|
|
141
|
+
private readonly signer: Signer
|
|
142
|
+
private readonly headers: RelayHeaders
|
|
143
|
+
private readonly transport: FetchLike | undefined
|
|
144
|
+
|
|
145
|
+
constructor(url: string, signer: Signer, options: RelayOptions | RelayHeaders = {}) {
|
|
146
|
+
this.url = url.replace(/\/+$/, '')
|
|
147
|
+
this.signer = signer
|
|
148
|
+
// A bare callback was the old second argument in Ship's client; accepting
|
|
149
|
+
// both keeps that call site working rather than making a behavioural change
|
|
150
|
+
// ride along with an extraction.
|
|
151
|
+
const opts: RelayOptions = typeof options === 'function' ? { headers: options } : options
|
|
152
|
+
this.headers = opts.headers ?? (() => ({}))
|
|
153
|
+
this.transport = opts.fetch
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async post(path: string, payload: unknown): Promise<{ status: number; text: string }> {
|
|
157
|
+
const url = `${this.url}${path}`
|
|
158
|
+
const body = JSON.stringify(payload)
|
|
159
|
+
const auth = await authorizationHeader(this.signer, { url, method: 'POST', body })
|
|
160
|
+
const send = this.transport ?? fetch
|
|
161
|
+
const res = await send(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'content-type': 'application/json',
|
|
165
|
+
...(await this.headers()),
|
|
166
|
+
// Rebuilt per request AND unique per request — identical requests in the
|
|
167
|
+
// same second would otherwise collide on the auth event id and the relay
|
|
168
|
+
// rejects the second as a replay. See nip98.ts on the nonce.
|
|
169
|
+
//
|
|
170
|
+
// Listed after the spread so a caller's headers cannot displace it:
|
|
171
|
+
// authorization is this client's own contract with the relay, not
|
|
172
|
+
// something an ambient credential gets to override.
|
|
173
|
+
authorization: auth,
|
|
174
|
+
},
|
|
175
|
+
body,
|
|
176
|
+
})
|
|
177
|
+
return { status: res.status, text: await res.text() }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async publish(event: SignedEvent): Promise<PublishResult> {
|
|
181
|
+
const { status, text } = await this.post('/events', event)
|
|
182
|
+
return parsePublishResponse(status, text)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `filters` is an array — the bridge expects `Vec<Value>`.
|
|
187
|
+
*
|
|
188
|
+
* Throws on a transport-level failure rather than returning an empty array,
|
|
189
|
+
* because "no events" and "the relay refused you" are not the same answer and
|
|
190
|
+
* a caller that cannot tell them apart renders an empty screen either way.
|
|
191
|
+
*/
|
|
192
|
+
async query(filters: Record<string, unknown>[]): Promise<SignedEvent[]> {
|
|
193
|
+
const { status, text } = await this.post('/query', filters)
|
|
194
|
+
const result = parseQueryResponse(status, text)
|
|
195
|
+
if (!result.ok) throw new Error(`query failed: ${result.reason}`)
|
|
196
|
+
return result.events
|
|
197
|
+
}
|
|
198
|
+
}
|