@nubemclaw/channel-nostr 2.0.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/LICENSE.md ADDED
@@ -0,0 +1,59 @@
1
+ # LICENSE — NubemClaw v3
2
+
3
+ **Copyright © 2026 Nubemsystems S.L.** All rights reserved.
4
+
5
+ NubemClaw v3 (this monorepo and every `@nubemclaw/*` package published
6
+ under it on the npm registry) is **proprietary software** distributed
7
+ publicly under the SPDX identifier **`UNLICENSED`**. Public
8
+ distribution via npm does NOT make this an open-source project. The
9
+ absence of a permissive license is intentional — the source is
10
+ visible for transparency and operator use, but the rights below apply.
11
+
12
+ ## Permitted
13
+
14
+ - Installing and running the published `@nubemclaw/*` packages from
15
+ npm for personal, internal-business, or research use.
16
+ - Reading the source code on the public repository.
17
+ - Submitting issues, pull requests, and feedback to the upstream
18
+ repository (`nubemsystemsdev/NubemClaw-v3`). Contributions are
19
+ accepted under the Contributor License Agreement (CLA) gated at PR
20
+ time; by submitting a contribution you grant Nubemsystems a
21
+ perpetual, worldwide, non-exclusive license to use the contribution
22
+ under this proprietary license.
23
+
24
+ ## NOT permitted (without prior written authorisation from Nubemsystems S.L.)
25
+
26
+ - Redistribution of the source or binaries outside the official npm
27
+ registry / GitHub release artifacts.
28
+ - Forking and republishing under a different name or scope.
29
+ - Removing or modifying the copyright notice in this file or in the
30
+ package manifests.
31
+ - Selling, sublicensing, or offering as a hosted SaaS that competes
32
+ with Nubemsystems' commercial offerings.
33
+ - Reverse-engineering the cloud control plane (when one exists) or
34
+ any non-source components shipped alongside.
35
+
36
+ ## Trademarks
37
+
38
+ "NubemClaw", "Nubemsystems", and associated logos are trademarks of
39
+ Nubemsystems S.L. They are not licensed by this document — using them
40
+ to identify your fork, derivative, or service requires explicit
41
+ written permission.
42
+
43
+ ## Warranty disclaimer
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
46
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
47
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
48
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
49
+ BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
50
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
51
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
52
+ SOFTWARE.
53
+
54
+ ## Contact
55
+
56
+ Licensing inquiries: `licensing@nubemsystems.es`.
57
+
58
+ Operator (single point of contact for v3): José Luis Manzanares
59
+ Fernández, Nubemsystems S.L. (`joseluis.manzanares@nubemsystems.es`).
@@ -0,0 +1,53 @@
1
+ import { type Channel, type ChannelCapabilities, type ChannelTarget } from "@nubemclaw/channel-sdk";
2
+ /**
3
+ * F31.b / F31-fix.1 — Nostr channel adapter.
4
+ *
5
+ * Nostr is NOT REST. The send path opens a WebSocket to a relay, then
6
+ * sends a `["EVENT", <signedEvent>]` frame (NIP-01). This adapter
7
+ * intentionally diverges from `createRestChannelBase` because the
8
+ * transport is a long-lived WS, not a one-shot fetch.
9
+ *
10
+ * Signing is intentionally OUT of this package — the adapter accepts
11
+ * a pre-built signer that returns `{id, sig, pubkey}` for a given
12
+ * event payload. The default signer is set up by the runner (which
13
+ * has access to the operator's nsec via secure storage); tests
14
+ * inject a deterministic signer.
15
+ *
16
+ * What this adapter PROVIDES:
17
+ *
18
+ * • A pure `buildNostrEvent` that produces the canonical
19
+ * `(kind:1, content, tags, created_at)` envelope.
20
+ * • A pure `buildEventFrame` that wraps the signed event into the
21
+ * `["EVENT", event]` array NIP-01 frame.
22
+ * • A `createNostrChannel(config)` factory that takes a
23
+ * `relayFactory` (test override) + `signer` and emits the WS
24
+ * frame on send().
25
+ */
26
+ export interface NostrSignerInput {
27
+ readonly created_at: number;
28
+ readonly kind: number;
29
+ readonly content: string;
30
+ readonly tags: readonly (readonly string[])[];
31
+ }
32
+ export interface NostrSignedEvent extends NostrSignerInput {
33
+ readonly id: string;
34
+ readonly pubkey: string;
35
+ readonly sig: string;
36
+ }
37
+ export type NostrSigner = (input: NostrSignerInput) => Promise<NostrSignedEvent>;
38
+ export interface NostrRelayLike {
39
+ send(frame: string): Promise<void>;
40
+ close(): Promise<void>;
41
+ }
42
+ export interface NostrChannelConfig {
43
+ readonly relayUrl: string;
44
+ readonly signer: NostrSigner;
45
+ readonly relayFactory?: (url: string) => Promise<NostrRelayLike>;
46
+ readonly capabilities?: ChannelCapabilities;
47
+ }
48
+ export declare const DEFAULT_NOSTR_RELAY: "wss://relay.damus.io";
49
+ export declare const NOSTR_TEXT_NOTE_KIND: 1;
50
+ export declare const buildNostrEvent: (target: ChannelTarget, text: string, now?: () => number) => NostrSignerInput;
51
+ export declare const buildEventFrame: (event: NostrSignedEvent) => string;
52
+ export declare const createNostrChannel: (config: NostrChannelConfig) => Channel;
53
+ //# sourceMappingURL=channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,OAAO,EACZ,KAAK,mBAAmB,EAGxB,KAAK,aAAa,EACnB,MAAM,wBAAwB,CAAC;AAEhC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC;CAC/C;AAED,MAAM,WAAW,gBAAiB,SAAQ,gBAAgB;IACxD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,gBAAgB,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAEjF,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IACjE,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;CAC7C;AAED,eAAO,MAAM,mBAAmB,EAAG,sBAA+B,CAAC;AACnE,eAAO,MAAM,oBAAoB,EAAG,CAAU,CAAC;AAE/C,eAAO,MAAM,eAAe,GAC1B,QAAQ,aAAa,EACrB,MAAM,MAAM,EACZ,MAAK,MAAM,MAA4C,KACtD,gBAKD,CAAC;AAEH,eAAO,MAAM,eAAe,GAAI,OAAO,gBAAgB,KAAG,MACxB,CAAC;AAEnC,eAAO,MAAM,kBAAkB,GAAI,QAAQ,kBAAkB,KAAG,OAuC/D,CAAC"}
@@ -0,0 +1,48 @@
1
+ import { TEXT_ONLY_REST_CAPABILITIES, extractMessageText, } from "@nubemclaw/channel-sdk";
2
+ export const DEFAULT_NOSTR_RELAY = "wss://relay.damus.io";
3
+ export const NOSTR_TEXT_NOTE_KIND = 1;
4
+ export const buildNostrEvent = (target, text, now = () => Math.floor(Date.now() / 1000)) => ({
5
+ created_at: now(),
6
+ kind: NOSTR_TEXT_NOTE_KIND,
7
+ content: text,
8
+ tags: [["p", target.id]],
9
+ });
10
+ export const buildEventFrame = (event) => JSON.stringify(["EVENT", event]);
11
+ export const createNostrChannel = (config) => {
12
+ let relay;
13
+ let deps;
14
+ const ensureRelay = async () => {
15
+ if (relay !== undefined)
16
+ return relay;
17
+ if (config.relayFactory === undefined) {
18
+ throw new Error("nostr adapter: no relayFactory configured. Provide one via config.relayFactory or wire the production WebSocket factory in the runner.");
19
+ }
20
+ relay = await config.relayFactory(config.relayUrl);
21
+ return relay;
22
+ };
23
+ return {
24
+ id: "nostr",
25
+ capabilities: config.capabilities ?? TEXT_ONLY_REST_CAPABILITIES,
26
+ async init(d) {
27
+ deps = d;
28
+ },
29
+ async start() {
30
+ void deps;
31
+ },
32
+ async stop() {
33
+ if (relay !== undefined) {
34
+ await relay.close();
35
+ relay = undefined;
36
+ }
37
+ },
38
+ async send(target, message) {
39
+ const text = extractMessageText(message);
40
+ const unsigned = buildNostrEvent(target, text);
41
+ const signed = await config.signer(unsigned);
42
+ const frame = buildEventFrame(signed);
43
+ const r = await ensureRelay();
44
+ await r.send(frame);
45
+ },
46
+ };
47
+ };
48
+ //# sourceMappingURL=channel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.js","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,kBAAkB,GAMnB,MAAM,wBAAwB,CAAC;AAsDhC,MAAM,CAAC,MAAM,mBAAmB,GAAG,sBAA+B,CAAC;AACnE,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAU,CAAC;AAE/C,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,MAAqB,EACrB,IAAY,EACZ,MAAoB,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,EACrC,EAAE,CAAC,CAAC;IACtB,UAAU,EAAE,GAAG,EAAE;IACjB,IAAI,EAAE,oBAAoB;IAC1B,OAAO,EAAE,IAAI;IACb,IAAI,EAAE,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;CACzB,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,KAAuB,EAAU,EAAE,CACjE,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;AAEnC,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,MAA0B,EAAW,EAAE;IACxE,IAAI,KAAiC,CAAC;IACtC,IAAI,IAA6B,CAAC;IAElC,MAAM,WAAW,GAAG,KAAK,IAA6B,EAAE;QACtD,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,KAAK,CAAC;QACtC,IAAI,MAAM,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CACb,wIAAwI,CACzI,CAAC;QACJ,CAAC;QACD,KAAK,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QACnD,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;IAEF,OAAO;QACL,EAAE,EAAE,OAAO;QACX,YAAY,EAAE,MAAM,CAAC,YAAY,IAAI,2BAA2B;QAChE,KAAK,CAAC,IAAI,CAAC,CAAc;YACvB,IAAI,GAAG,CAAC,CAAC;QACX,CAAC;QACD,KAAK,CAAC,KAAK;YACT,KAAK,IAAI,CAAC;QACZ,CAAC;QACD,KAAK,CAAC,IAAI;YACR,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;gBACpB,KAAK,GAAG,SAAS,CAAC;YACpB,CAAC;QACH,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,MAAqB,EAAE,OAAuB;YACvD,MAAM,IAAI,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;YACzC,MAAM,QAAQ,GAAG,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;YAC/C,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YAC7C,MAAM,KAAK,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;YACtC,MAAM,CAAC,GAAG,MAAM,WAAW,EAAE,CAAC;YAC9B,MAAM,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;KACF,CAAC;AACJ,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export * from "./channel.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./channel.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC"}
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@nubemclaw/channel-nostr",
3
+ "version": "2.0.0",
4
+ "description": "NubemClaw v3 — Nostr channel (F31.b). Nostr relay protocol (event publish).",
5
+ "license": "UNLICENSED",
6
+ "files": [
7
+ "dist",
8
+ "src"
9
+ ],
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "dependencies": {
20
+ "zod": "^3.23.8",
21
+ "@nubemclaw/core": "2.0.0",
22
+ "@nubemclaw/channel-sdk": "2.0.0"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -b",
26
+ "clean": "tsc -b --clean && rm -rf dist .cache"
27
+ }
28
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ DEFAULT_NOSTR_RELAY,
5
+ NOSTR_TEXT_NOTE_KIND,
6
+ buildEventFrame,
7
+ buildNostrEvent,
8
+ createNostrChannel,
9
+ type NostrRelayLike,
10
+ type NostrSignedEvent,
11
+ type NostrSigner,
12
+ } from "./channel.js";
13
+ import type { ChannelDeps } from "@nubemclaw/channel-sdk";
14
+
15
+ const fakeDeps = (): ChannelDeps => ({
16
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
17
+ state: { get: async () => undefined, set: async () => {}, delete: async () => {} },
18
+ allowlist: { allows: () => true, size: 0 },
19
+ onInbound: async () => {},
20
+ });
21
+
22
+ const detSigner: NostrSigner = async (input) => ({
23
+ ...input,
24
+ id: "0000000000000000000000000000000000000000000000000000000000000000",
25
+ pubkey: "1111111111111111111111111111111111111111111111111111111111111111",
26
+ sig: "2222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222",
27
+ });
28
+
29
+ const fakeRelay = (): NostrRelayLike & { sent: string[]; closed: boolean } => {
30
+ const sent: string[] = [];
31
+ let closed = false;
32
+ return {
33
+ sent,
34
+ get closed() {
35
+ return closed;
36
+ },
37
+ set closed(v) {
38
+ closed = v;
39
+ },
40
+ async send(frame) {
41
+ sent.push(frame);
42
+ },
43
+ async close() {
44
+ closed = true;
45
+ },
46
+ };
47
+ };
48
+
49
+ describe("buildNostrEvent", () => {
50
+ it("emits kind=1 (text note) with content + p-tag pointing to the recipient", () => {
51
+ const ev = buildNostrEvent({ kind: "chat", id: "npub1abc" }, "hello", () => 1_700_000_000);
52
+ expect(ev.kind).toBe(NOSTR_TEXT_NOTE_KIND);
53
+ expect(ev.content).toBe("hello");
54
+ expect(ev.tags).toEqual([["p", "npub1abc"]]);
55
+ expect(ev.created_at).toBe(1_700_000_000);
56
+ });
57
+
58
+ it("uses the injected `now` for deterministic timestamps", () => {
59
+ const ev = buildNostrEvent({ kind: "chat", id: "x" }, "y", () => 42);
60
+ expect(ev.created_at).toBe(42);
61
+ });
62
+ });
63
+
64
+ describe("buildEventFrame", () => {
65
+ it('wraps the signed event into a NIP-01 ["EVENT", event] array', () => {
66
+ const ev: NostrSignedEvent = {
67
+ created_at: 1,
68
+ kind: NOSTR_TEXT_NOTE_KIND,
69
+ content: "x",
70
+ tags: [],
71
+ id: "deadbeef",
72
+ pubkey: "feedface",
73
+ sig: "abcd",
74
+ };
75
+ const frame = JSON.parse(buildEventFrame(ev));
76
+ expect(frame[0]).toBe("EVENT");
77
+ expect(frame[1]).toEqual(ev);
78
+ });
79
+ });
80
+
81
+ describe("createNostrChannel", () => {
82
+ it("exposes a Channel with id=nostr", () => {
83
+ const ch = createNostrChannel({
84
+ relayUrl: DEFAULT_NOSTR_RELAY,
85
+ signer: detSigner,
86
+ relayFactory: async () => fakeRelay(),
87
+ });
88
+ expect(ch.id).toBe("nostr");
89
+ });
90
+
91
+ it("send() signs the event and writes the EVENT frame to the relay", async () => {
92
+ const relay = fakeRelay();
93
+ const ch = createNostrChannel({
94
+ relayUrl: "wss://test-relay",
95
+ signer: detSigner,
96
+ relayFactory: async () => relay,
97
+ });
98
+ await ch.init(fakeDeps());
99
+ await ch.send({ kind: "chat", id: "npub1xyz" }, { type: "text", text: "hello nostr" });
100
+ expect(relay.sent).toHaveLength(1);
101
+ const frame = JSON.parse(relay.sent[0] ?? "[]");
102
+ expect(frame[0]).toBe("EVENT");
103
+ expect(frame[1].kind).toBe(NOSTR_TEXT_NOTE_KIND);
104
+ expect(frame[1].content).toBe("hello nostr");
105
+ expect(frame[1].tags).toEqual([["p", "npub1xyz"]]);
106
+ expect(frame[1].sig).toMatch(/^[0-9a-f]+$/);
107
+ });
108
+
109
+ it("send() throws when no relayFactory provided", async () => {
110
+ const ch = createNostrChannel({
111
+ relayUrl: "wss://x",
112
+ signer: detSigner,
113
+ });
114
+ await ch.init(fakeDeps());
115
+ await expect(ch.send({ kind: "chat", id: "n" }, { type: "text", text: "x" })).rejects.toThrow(
116
+ /no relayFactory/,
117
+ );
118
+ });
119
+
120
+ it("stop() closes the relay", async () => {
121
+ const relay = fakeRelay();
122
+ const ch = createNostrChannel({
123
+ relayUrl: "wss://x",
124
+ signer: detSigner,
125
+ relayFactory: async () => relay,
126
+ });
127
+ await ch.init(fakeDeps());
128
+ await ch.send({ kind: "chat", id: "n" }, { type: "text", text: "x" });
129
+ await ch.stop();
130
+ expect(relay.closed).toBe(true);
131
+ });
132
+
133
+ it("DEFAULT_NOSTR_RELAY uses wss://relay.damus.io", () => {
134
+ expect(DEFAULT_NOSTR_RELAY).toBe("wss://relay.damus.io");
135
+ });
136
+ });
package/src/channel.ts ADDED
@@ -0,0 +1,119 @@
1
+ import {
2
+ TEXT_ONLY_REST_CAPABILITIES,
3
+ extractMessageText,
4
+ type Channel,
5
+ type ChannelCapabilities,
6
+ type ChannelDeps,
7
+ type ChannelMessage,
8
+ type ChannelTarget,
9
+ } from "@nubemclaw/channel-sdk";
10
+
11
+ /**
12
+ * F31.b / F31-fix.1 — Nostr channel adapter.
13
+ *
14
+ * Nostr is NOT REST. The send path opens a WebSocket to a relay, then
15
+ * sends a `["EVENT", <signedEvent>]` frame (NIP-01). This adapter
16
+ * intentionally diverges from `createRestChannelBase` because the
17
+ * transport is a long-lived WS, not a one-shot fetch.
18
+ *
19
+ * Signing is intentionally OUT of this package — the adapter accepts
20
+ * a pre-built signer that returns `{id, sig, pubkey}` for a given
21
+ * event payload. The default signer is set up by the runner (which
22
+ * has access to the operator's nsec via secure storage); tests
23
+ * inject a deterministic signer.
24
+ *
25
+ * What this adapter PROVIDES:
26
+ *
27
+ * • A pure `buildNostrEvent` that produces the canonical
28
+ * `(kind:1, content, tags, created_at)` envelope.
29
+ * • A pure `buildEventFrame` that wraps the signed event into the
30
+ * `["EVENT", event]` array NIP-01 frame.
31
+ * • A `createNostrChannel(config)` factory that takes a
32
+ * `relayFactory` (test override) + `signer` and emits the WS
33
+ * frame on send().
34
+ */
35
+
36
+ export interface NostrSignerInput {
37
+ readonly created_at: number;
38
+ readonly kind: number;
39
+ readonly content: string;
40
+ readonly tags: readonly (readonly string[])[];
41
+ }
42
+
43
+ export interface NostrSignedEvent extends NostrSignerInput {
44
+ readonly id: string;
45
+ readonly pubkey: string;
46
+ readonly sig: string;
47
+ }
48
+
49
+ export type NostrSigner = (input: NostrSignerInput) => Promise<NostrSignedEvent>;
50
+
51
+ export interface NostrRelayLike {
52
+ send(frame: string): Promise<void>;
53
+ close(): Promise<void>;
54
+ }
55
+
56
+ export interface NostrChannelConfig {
57
+ readonly relayUrl: string;
58
+ readonly signer: NostrSigner;
59
+ readonly relayFactory?: (url: string) => Promise<NostrRelayLike>;
60
+ readonly capabilities?: ChannelCapabilities;
61
+ }
62
+
63
+ export const DEFAULT_NOSTR_RELAY = "wss://relay.damus.io" as const;
64
+ export const NOSTR_TEXT_NOTE_KIND = 1 as const;
65
+
66
+ export const buildNostrEvent = (
67
+ target: ChannelTarget,
68
+ text: string,
69
+ now: () => number = () => Math.floor(Date.now() / 1000),
70
+ ): NostrSignerInput => ({
71
+ created_at: now(),
72
+ kind: NOSTR_TEXT_NOTE_KIND,
73
+ content: text,
74
+ tags: [["p", target.id]],
75
+ });
76
+
77
+ export const buildEventFrame = (event: NostrSignedEvent): string =>
78
+ JSON.stringify(["EVENT", event]);
79
+
80
+ export const createNostrChannel = (config: NostrChannelConfig): Channel => {
81
+ let relay: NostrRelayLike | undefined;
82
+ let deps: ChannelDeps | undefined;
83
+
84
+ const ensureRelay = async (): Promise<NostrRelayLike> => {
85
+ if (relay !== undefined) return relay;
86
+ if (config.relayFactory === undefined) {
87
+ throw new Error(
88
+ "nostr adapter: no relayFactory configured. Provide one via config.relayFactory or wire the production WebSocket factory in the runner.",
89
+ );
90
+ }
91
+ relay = await config.relayFactory(config.relayUrl);
92
+ return relay;
93
+ };
94
+
95
+ return {
96
+ id: "nostr",
97
+ capabilities: config.capabilities ?? TEXT_ONLY_REST_CAPABILITIES,
98
+ async init(d: ChannelDeps): Promise<void> {
99
+ deps = d;
100
+ },
101
+ async start(): Promise<void> {
102
+ void deps;
103
+ },
104
+ async stop(): Promise<void> {
105
+ if (relay !== undefined) {
106
+ await relay.close();
107
+ relay = undefined;
108
+ }
109
+ },
110
+ async send(target: ChannelTarget, message: ChannelMessage): Promise<void> {
111
+ const text = extractMessageText(message);
112
+ const unsigned = buildNostrEvent(target, text);
113
+ const signed = await config.signer(unsigned);
114
+ const frame = buildEventFrame(signed);
115
+ const r = await ensureRelay();
116
+ await r.send(frame);
117
+ },
118
+ };
119
+ };
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./channel.js";