@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/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
@@ -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
+ }