@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/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.
|
package/dist/bridge.d.ts
ADDED
|
@@ -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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"}
|