@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 +59 -0
- package/dist/channel.d.ts +53 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +48 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -0
- package/src/channel.test.ts +136 -0
- package/src/channel.ts +119 -0
- package/src/index.ts +1 -0
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"}
|
package/dist/channel.js
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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";
|