@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 ADDED
@@ -0,0 +1,57 @@
1
+ # @estiva-app/protocol
2
+
3
+ Every entry answers the wire question explicitly, including when the answer is
4
+ nothing (ADR 0002 §4b). A change to the bytes an app publishes is a MAJOR — in
5
+ `0.x`, a MINOR — even when no TypeScript signature moved.
6
+
7
+ ## 0.1.0 — 2026-08-27
8
+
9
+ First release. SHA-3.
10
+
11
+ **Wire behaviour: unchanged.** This is an extraction, not a change. The bytes are
12
+ pinned to what `peek-app/convex/nostr/` and `estiva-ship/lib/nostr/` were
13
+ producing before the package existed — `test/wire-vectors.json`, recorded from
14
+ those two trees at `98d284f` and `82e048b` — and all 12 event shapes both apps
15
+ implemented had byte-identical ids in both. Nothing in this release makes an app
16
+ publish a different event than it published yesterday.
17
+
18
+ Two things arrive here as the union of what the copies had, and neither changes
19
+ an existing app's output:
20
+
21
+ - **`buildMessage` takes `about`.** Peek's builder emitted `a` tags for
22
+ cross-app routing; Ship's had no such parameter and could not emit one at all.
23
+ Peek's shape ships. Omitting `about` produces byte-identical output to Ship's
24
+ old builder — the `message-*` vectors pin both arms.
25
+ - **`KIND` is the union of both apps' constants.** A kind number is a fact about
26
+ the relay, not about an app, and a subset is how an app ends up unable to
27
+ *read* a kind its neighbour writes. Ship gains `NIP29_EDIT_METADATA`,
28
+ `NIP29_DELETE_GROUP`, `ASSERTION`, `COMMENT` and `RELAY_AUTH` in its type
29
+ surface; Peek gains `HIGHLIGHT`, `FILE` and `COMPONENT`. Nothing publishes
30
+ anything new as a result.
31
+
32
+ One deliberate API reconciliation, called out because it is the one place a call
33
+ site had to change:
34
+
35
+ - **NIP-98 exports an unsigned builder plus a signer**, which was Peek's shape.
36
+ Ship had a combined `buildAuthEvent(args & { secretKeyHex })`. The unsigned
37
+ form is the one that works for an app holding no keys, and the combined form is
38
+ `signEvent(buildUnsignedAuthEvent(args), secret)` — one line. The *tags* the
39
+ two produced were already identical; the `nip98-auth-*` vectors pin that.
40
+
41
+ Also here, moved rather than written: the **relay socket client** (`createLiveRelay`,
42
+ PEE-5) and the **refcounted per-channel subscription manager**
43
+ (`createChannelSubscriptions`, PEE-6). Both were built inside `peek-app` because
44
+ Gate 2 had not happened when they were due, and both were Peek-only until now.
45
+ The only behavioural difference is that the REQ subscription id prefix is a
46
+ `subscriptionPrefix` option instead of the hardcoded `peek-`; it appears in the
47
+ relay's logs and nowhere else.
48
+
49
+ `Relay`'s second argument now accepts either the bare headers callback it took in
50
+ Ship or an options object (`{ headers, fetch }`), so the existing call sites bind
51
+ unchanged and a test can inject a transport.
52
+
53
+ ### What this release does not include
54
+
55
+ The fold, and anything that interprets events. `foldFolder` stays in Ship,
56
+ `foldResolution` and the projection stay in Peek, and each app keeps its own
57
+ conformance fixture. See the README on the line this package does not cross.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Estiva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @estiva-app/protocol
2
+
3
+ The Estiva wire format, once. Buzz-shaped Nostr event builders, the NIP-01 id
4
+ preimage, NIP-19 `naddr`, NIP-98 HTTP auth, Schnorr signing, and the two relay
5
+ clients.
6
+
7
+ ```bash
8
+ npm install @estiva-app/protocol
9
+ ```
10
+
11
+ No registry auth, no `.npmrc`, no token. That is [ADR 0002 §3](../../../estiva-docs/decisions/0002-foundation-packages.md).
12
+
13
+ ## The line this package does not cross
14
+
15
+ **In: the bytes.** Everything whose correctness the *relay* judges — event
16
+ construction, tag order, the id hash, the signature input, `naddr`, the NIP-98
17
+ header, and the `accepted` semantics of the HTTP bridge.
18
+
19
+ **Not in: the fold, or anything that interprets events.** How an app turns a
20
+ stream of events into current truth is where apps are *supposed* to differ
21
+ (ADR 0001 §8). `foldFolder` stays in Ship, `foldResolution` and the projection
22
+ stay in Peek, and each app's conformance fixture stays with it.
23
+
24
+ When adding something here, the test is not "both apps need it". It is **"would
25
+ the relay notice if the two apps disagreed?"**
26
+
27
+ ## Why one implementation, when the suite's whole claim is independence
28
+
29
+ SPEC §10 used to argue the duplication *was* the architecture: three apps each
30
+ implementing NIP-01 serialization is what makes "apps sharing no code work on the
31
+ same data" a real statement rather than a claim about siblings.
32
+
33
+ That was right about the claim and wrong about the mechanism, and the third copy
34
+ settled it. A second hand-written event-id hash is not a demonstration of
35
+ independence, it is a divergence the relay notices and we do not — and it had
36
+ already happened. Peek's `buildMessage` grew an `about` parameter emitting `a`
37
+ tags; Ship's never did. The same logical message produced different bytes
38
+ depending on which app sent it, **nothing failed, and each copy was
39
+ self-consistent.** Between the other two copies, a `diff -r` somebody had to
40
+ remember to run was the entire safety mechanism.
41
+
42
+ What makes the interop claim true is that the apps share no interpretation and no
43
+ database — still exactly the case. They now agree on the wire format on purpose
44
+ rather than by coincidence. SPEC §10 was amended by SHA-3 to say so.
45
+
46
+ ## A MAJOR here is a protocol event, not a TypeScript event
47
+
48
+ A change to the bytes an app publishes — id computation, serialization order, tag
49
+ semantics, signature input — is a MAJOR **even when the TypeScript signature is
50
+ identical** (ADR 0002 §4b). Every release note answers the wire question
51
+ explicitly, including when the answer is:
52
+
53
+ > **Wire behaviour:** unchanged.
54
+
55
+ A missing line is what lets a bytes-changing release pass as a refactor.
56
+
57
+ ## What holds that claim up
58
+
59
+ Four checks in CI and a fifth by hand, and the important thing about them is
60
+ that none is a green tick next to a value somebody chose.
61
+
62
+ | check | what it is |
63
+ | --- | --- |
64
+ | `test/wire.test.mjs` | 21 vectors **recorded from `peek-app/convex/nostr/` and `estiva-ship/lib/nostr/` before this package existed** — the bytes the two apps were already publishing. All 12 shapes both apps implemented had identical ids in both. |
65
+ | the same file, `production kind:*` | 7 events read back off `https://estiva.estiva.app`. Their `id` and `sig` are the **relay's**, so reproducing the id checks against the authority rather than against ourselves. Sampled from the 96 *recorded* events in Ship's fold fixture, not the 7 adversarial ones beside them — those are hand-built and were never published, and one of them sat in this set until the live check queried it back and could not find it. |
66
+ | `test/oracle.test.mjs` | `nostr-tools` as an independent implementation, with a negative control that fails when the bytes are wrong. Plus the canonical NIP-19 `npub` vector from the specification, which is the only thing that catches bech32m-instead-of-bech32. |
67
+ | `test/runtime.test.mjs` | the barrel imports and works with `WebSocket`, `fetch`, `btoa`, `document`, `process`, `Buffer` and friends deleted — with its own negative control. |
68
+
69
+ **Do not regenerate `wire-vectors.json` to make a failing test pass.** That erases
70
+ the finding. Regenerating is correct only when a builder is deliberately given a
71
+ new shape, and then the release note says so under *Wire behaviour*.
72
+
73
+ A green suite is still not the last word. The relay is the only authority on
74
+ whether the wire format is right, and a passing test run is compatible with a
75
+ rejected event. So there is a fifth check, and it is hand-run because it needs a
76
+ real workspace credential — and a credential in CI is the standing secret ADR
77
+ 0002 §4c spent a failed release deciding not to have:
78
+
79
+ ```bash
80
+ npm run build -w packages/protocol && set -a && . ~/.estiva-agent.env && set +a && npm run verify:live -w packages/protocol
81
+ ```
82
+
83
+ It publishes a **real signed `kind:9007`** naming a channel that already exists,
84
+ so the relay answers `200 {"accepted":false,"duplicate: channel already
85
+ exists"}` — proof the event was parsed, its id recomputed and its signature
86
+ verified — while creating nothing. Then two negative controls, without which "it
87
+ works" is indistinguishable from "the rule was removed":
88
+
89
+ - the same signed event with **two tags swapped** and `id`/`sig` left alone. The
90
+ relay's own words, 2026-08-27: `400 invalid: invalid event id: computed
91
+ 70f206d4…, got b9d1e606…`. It recomputes the id independently, so agreement on
92
+ the good event is agreement about the bytes rather than indifference to them.
93
+ - a `kind:0`, which `/sign` refuses for every app unconditionally —
94
+ `422 policy_violation`. The gates are live, not open.
95
+
96
+ Run it before tagging any release whose *Wire behaviour* line is not
97
+ "unchanged", and again afterwards against the **published tarball** rather than
98
+ the workspace, because built is not published.
99
+
100
+ ## `nostr-tools` was evaluated, and is a devDependency rather than a dependency
101
+
102
+ SHA-3 asked for an hour on this before packaging a hand-rolled hash. Measured,
103
+ 2026-08-27, against `nostr-tools@2.25.0`:
104
+
105
+ | question | answer |
106
+ | --- | --- |
107
+ | does `getEventHash` agree with `computeEventId`? | **Yes, on all 103 events** recorded from production, and on every vector here. `verifyEvent` verified all 103 relay signatures. |
108
+ | does `nip19` round-trip our pointers? | **Yes, exactly** — including a `d` tag containing colons. |
109
+ | is its `naddr` string the same as ours? | **No.** It emits the TLVs in a different order, so the same pointer encodes to a different string. Every decoder involved reads TLVs by type and is order-tolerant, so they interoperate — but naddrs must never be compared as strings. |
110
+ | does its NIP-98 fit? | **No.** `getToken` emits `u`, `method`, `payload` and **no nonce**. Buzz records each auth event id in a Redis replay set and `created_at` has one-second resolution, so two identical requests in one second would collide and the second is refused as `NIP-98: replay detected`. Ours adds a nonce for exactly that reason. |
111
+ | its NIP-42? | `makeAuthEvent` emits relay-then-challenge, which happens to match ours. Buzz looks both tags up by name, so it is not load-bearing either way. |
112
+ | cost of depending on it | it needs `@noble/hashes@2` and `@noble/curves@2`; Peek is on `^1.8.0`/`^1.9.2` and Ship pins `1.8.0`/`1.9.2`. Adopting it puts **two majors of `@noble/*` in every consumer's tree**, and noble v2 renamed its subpaths (`/sha256` → `/sha2`), so it is not a drop-in. |
113
+ | what it does not cover at all | the Buzz kind constants, `canonicalChannelName`, the tag layouts Buzz validates (`h`, the NIP-10 marked-reply form, the `a` about tag), the 30850/30851/1851 shapes, and the `accepted`-field semantics of the bridge. Those stay ours regardless. |
114
+
115
+ **Decision: keep our own thin implementation and use `nostr-tools` as an
116
+ independent oracle in the test suite.** That is strictly better than either option
117
+ the ticket floated — a third-party cross-check with no dependency, no second
118
+ noble, and no bytes for consumers. It is also what replaces `diff -r`: a check
119
+ that fails on its own rather than one somebody has to remember to run.
120
+
121
+ ## Runtime requirements, and why they are not in the types
122
+
123
+ The package compiles with `types: []` and no `lib: dom` so one published `.d.ts`
124
+ works in Peek's Convex tree, Peek's browser bundle and the agent's `tsx` run
125
+ (ADR 0002 §4a). None of `URL`, `btoa`, `fetch`, `WebSocket`, `TextDecoder` or the
126
+ timer functions is in `lib.es2022`, so each is **declared inside the module that
127
+ needs it** and **read inside a function body**:
128
+
129
+ - declaring it inside a module means nothing lands in a consumer's global scope;
130
+ - reading it inside a function means importing the package touches no global, so
131
+ a backend that only wants the event builders does not crash on a `WebSocket`
132
+ its runtime has never heard of.
133
+
134
+ The transport-shaped ones are parameters as well as globals: `Relay` takes an
135
+ optional `fetch`, `createLiveRelay` takes an optional `socketFactory`, and the
136
+ defaults read the runtime's own.
137
+
138
+ One honest exception, found by writing the test: **`@noble/curves` reads
139
+ `TextEncoder` while its module body runs.** So "no globals at all" is not a
140
+ property this package can have while it depends on `@noble/*`. `TextEncoder`,
141
+ `TextDecoder` and `URL` are WHATWG universals present in every runtime the suite
142
+ targets, and that is the line.
143
+
144
+ ## Consumers
145
+
146
+ Peek, Ship and `estiva-agent` — see [CONSUMERS.md](../../CONSUMERS.md). A
147
+ breaking change is not published until every one of them has an open upgrade PR,
148
+ authored by whoever makes the break (ADR 0002 §5).
149
+
150
+ ## Releasing
151
+
152
+ ```bash
153
+ git tag protocol@0.1.1 && git push origin protocol@0.1.1
154
+ ```
155
+
156
+ `release.yml` checks the tag against `package.json`, builds, and publishes
157
+ through trusted publishing (OIDC). No npm credential exists in GitHub. See the
158
+ [repo README](../../README.md) for the once-per-package manual first publish.
@@ -0,0 +1,91 @@
1
+ import type { SignedEvent } from './events.js';
2
+ import type { Signer } from './sign.js';
3
+ export interface PublishResult {
4
+ ok: boolean;
5
+ eventId?: string;
6
+ reason?: string;
7
+ /** The relay already had this state — not a failure. */
8
+ duplicate?: boolean;
9
+ /** The HTTP status, for a caller that wants to distinguish 403 from 500. */
10
+ httpStatus?: number;
11
+ }
12
+ /**
13
+ * The relay's answer to `POST /events`, interpreted.
14
+ *
15
+ * `duplicate` is reported as `ok`: "already exists" is the desired end state, so
16
+ * a caller creating a channel that is already there has succeeded. Every other
17
+ * `accepted: false` is a failure the caller must surface — SPEC §9's C9, which
18
+ * is not decoration: once no app can sign locally, an identity-service outage
19
+ * looks exactly like nothing happening.
20
+ */
21
+ export declare function parsePublishResponse(status: number, text: string): PublishResult;
22
+ export interface QueryResult {
23
+ ok: boolean;
24
+ events: SignedEvent[];
25
+ reason?: string;
26
+ httpStatus?: number;
27
+ }
28
+ /** The relay's answer to `POST /query`, interpreted. */
29
+ export declare function parseQueryResponse(status: number, text: string): QueryResult;
30
+ /**
31
+ * Just enough of `fetch` to post a body and read the answer.
32
+ *
33
+ * A parameter rather than an ambient global, per ADR 0002 §4a — `fetch` is in
34
+ * neither `lib.es2022` nor a package that may assume `lib.dom`. The default
35
+ * below reads the runtime's own, so no consumer has to pass one.
36
+ */
37
+ export type FetchLike = (url: string, init: {
38
+ method: string;
39
+ headers: Record<string, string>;
40
+ body: string;
41
+ }) => Promise<{
42
+ status: number;
43
+ text(): Promise<string>;
44
+ }>;
45
+ /** Extra headers to send with every relay request, resolved per request. */
46
+ export type RelayHeaders = () => Record<string, string> | Promise<Record<string, string>>;
47
+ export interface RelayOptions {
48
+ /**
49
+ * ## Why `headers` is a callback
50
+ *
51
+ * Buzz reads more than NIP-98 off a request. A NIP-OA owner attestation travels
52
+ * in `x-auth-tag`, and on a closed relay it is what admits an agent whose
53
+ * *owner* is a member — so it is required on **every** call, `/query` included,
54
+ * not just on writes.
55
+ *
56
+ * A callback rather than a fixed object because what it carries can expire: an
57
+ * agent's attestation is minted with its token and replaced when that token is
58
+ * renewed. A snapshot taken at construction would work for one TTL and then
59
+ * fail as a `403 relay_membership_required` that looks nothing like an expiry.
60
+ *
61
+ * Empty by default, so an app with no such credential is unaffected.
62
+ */
63
+ headers?: RelayHeaders;
64
+ /** Override the transport. Defaults to the runtime's `fetch`. */
65
+ fetch?: FetchLike;
66
+ }
67
+ /**
68
+ * A relay client that signs its own NIP-98 auth.
69
+ *
70
+ * Takes a {@link Signer} rather than a key or a token, which is what lets the
71
+ * same class serve a script holding a secret key and a browser signing through a
72
+ * remote service.
73
+ */
74
+ export declare class Relay {
75
+ private readonly url;
76
+ private readonly signer;
77
+ private readonly headers;
78
+ private readonly transport;
79
+ constructor(url: string, signer: Signer, options?: RelayOptions | RelayHeaders);
80
+ private post;
81
+ publish(event: SignedEvent): Promise<PublishResult>;
82
+ /**
83
+ * `filters` is an array — the bridge expects `Vec<Value>`.
84
+ *
85
+ * Throws on a transport-level failure rather than returning an empty array,
86
+ * because "no events" and "the relay refused you" are not the same answer and
87
+ * a caller that cannot tell them apart renders an empty screen either way.
88
+ */
89
+ query(filters: Record<string, unknown>[]): Promise<SignedEvent[]>;
90
+ }
91
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAC9C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAEvC,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAehF;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAgB5E;AAED;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,GAAG,CACtB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,KACpE,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC,CAAA;AAKzD,4EAA4E;AAC5E,MAAM,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;AAEzF,MAAM,WAAW,YAAY;IAC3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,iEAAiE;IACjE,KAAK,CAAC,EAAE,SAAS,CAAA;CAClB;AAED;;;;;;GAMG;AACH,qBAAa,KAAK;IAChB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAc;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAuB;gBAErC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,YAAY,GAAG,YAAiB;YAWpE,IAAI;IAwBZ,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC;IAKzD;;;;;;OAMG;IACG,KAAK,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;CAMxE"}
package/dist/bridge.js ADDED
@@ -0,0 +1,138 @@
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
+ /**
29
+ * The relay's answer to `POST /events`, interpreted.
30
+ *
31
+ * `duplicate` is reported as `ok`: "already exists" is the desired end state, so
32
+ * a caller creating a channel that is already there has succeeded. Every other
33
+ * `accepted: false` is a failure the caller must surface — SPEC §9's C9, which
34
+ * is not decoration: once no app can sign locally, an identity-service outage
35
+ * looks exactly like nothing happening.
36
+ */
37
+ export function parsePublishResponse(status, text) {
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(text);
41
+ }
42
+ catch {
43
+ return { ok: false, reason: `unparseable response: ${text.slice(0, 200)}`, httpStatus: status };
44
+ }
45
+ if (status < 200 || status >= 300) {
46
+ return { ok: false, reason: parsed.error ?? text.slice(0, 200), httpStatus: status };
47
+ }
48
+ if (parsed.accepted === false) {
49
+ const duplicate = (parsed.message ?? '').startsWith('duplicate:');
50
+ return { ok: duplicate, duplicate, eventId: parsed.event_id, reason: parsed.message, httpStatus: status };
51
+ }
52
+ return { ok: true, eventId: parsed.event_id, httpStatus: status };
53
+ }
54
+ /** The relay's answer to `POST /query`, interpreted. */
55
+ export function parseQueryResponse(status, text) {
56
+ if (status < 200 || status >= 300) {
57
+ let reason = text.slice(0, 200);
58
+ try {
59
+ reason = JSON.parse(text).error ?? reason;
60
+ }
61
+ catch {
62
+ // keep the raw text
63
+ }
64
+ return { ok: false, events: [], reason, httpStatus: status };
65
+ }
66
+ try {
67
+ const parsed = JSON.parse(text);
68
+ return { ok: true, events: Array.isArray(parsed) ? parsed : [], httpStatus: status };
69
+ }
70
+ catch {
71
+ return { ok: false, events: [], reason: `unparseable response: ${text.slice(0, 200)}`, httpStatus: status };
72
+ }
73
+ }
74
+ /**
75
+ * A relay client that signs its own NIP-98 auth.
76
+ *
77
+ * Takes a {@link Signer} rather than a key or a token, which is what lets the
78
+ * same class serve a script holding a secret key and a browser signing through a
79
+ * remote service.
80
+ */
81
+ export class Relay {
82
+ url;
83
+ signer;
84
+ headers;
85
+ transport;
86
+ constructor(url, signer, options = {}) {
87
+ this.url = url.replace(/\/+$/, '');
88
+ this.signer = signer;
89
+ // A bare callback was the old second argument in Ship's client; accepting
90
+ // both keeps that call site working rather than making a behavioural change
91
+ // ride along with an extraction.
92
+ const opts = typeof options === 'function' ? { headers: options } : options;
93
+ this.headers = opts.headers ?? (() => ({}));
94
+ this.transport = opts.fetch;
95
+ }
96
+ async post(path, payload) {
97
+ const url = `${this.url}${path}`;
98
+ const body = JSON.stringify(payload);
99
+ const auth = await authorizationHeader(this.signer, { url, method: 'POST', body });
100
+ const send = this.transport ?? fetch;
101
+ const res = await send(url, {
102
+ method: 'POST',
103
+ headers: {
104
+ 'content-type': 'application/json',
105
+ ...(await this.headers()),
106
+ // Rebuilt per request AND unique per request — identical requests in the
107
+ // same second would otherwise collide on the auth event id and the relay
108
+ // rejects the second as a replay. See nip98.ts on the nonce.
109
+ //
110
+ // Listed after the spread so a caller's headers cannot displace it:
111
+ // authorization is this client's own contract with the relay, not
112
+ // something an ambient credential gets to override.
113
+ authorization: auth,
114
+ },
115
+ body,
116
+ });
117
+ return { status: res.status, text: await res.text() };
118
+ }
119
+ async publish(event) {
120
+ const { status, text } = await this.post('/events', event);
121
+ return parsePublishResponse(status, text);
122
+ }
123
+ /**
124
+ * `filters` is an array — the bridge expects `Vec<Value>`.
125
+ *
126
+ * Throws on a transport-level failure rather than returning an empty array,
127
+ * because "no events" and "the relay refused you" are not the same answer and
128
+ * a caller that cannot tell them apart renders an empty screen either way.
129
+ */
130
+ async query(filters) {
131
+ const { status, text } = await this.post('/query', filters);
132
+ const result = parseQueryResponse(status, text);
133
+ if (!result.ok)
134
+ throw new Error(`query failed: ${result.reason}`);
135
+ return result.events;
136
+ }
137
+ }
138
+ //# sourceMappingURL=bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.js","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"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"}