Refactor primitives into namespaces.

This commit is contained in:
Jay
2025-10-31 20:44:36 -04:00
parent c7935326e9
commit 2f1ed9b6d5
29 changed files with 192 additions and 275 deletions

View File

@@ -34,8 +34,14 @@ npm install @wisehodl/roots
2. Import it: 2. Import it:
```typescript ```typescript
import { Event, Filter, Keys } from '@wisehodl/roots'; import * as events from '@wisehodl/roots/events';
import type { EventData, FilterData } from '@wisehodl/roots'; import * as filters from '@wisehodl/roots/filters';
import * as keys from '@wisehodl/roots/keys';
import * as constants from '@wisehodl/roots/constants';
import * as errors from '@wisehodl/roots/errors';
import type { Event } from '@wisehodl/roots/events';
import type { Filter } from '@wisehodl/roots/filters';
``` ```
## Usage Examples ## Usage Examples
@@ -45,15 +51,15 @@ import type { EventData, FilterData } from '@wisehodl/roots';
#### Generate a new keypair #### Generate a new keypair
```typescript ```typescript
const privateKey = Keys.generatePrivateKey(); const privateKey = keys.generatePrivateKey();
const publicKey = Keys.getPublicKey(privateKey); const publicKey = keys.getPublicKey(privateKey);
``` ```
#### Derive public key from existing private key #### Derive public key from existing private key
```typescript ```typescript
const privateKey = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"; const privateKey = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167";
const publicKey = Keys.getPublicKey(privateKey); const publicKey = keys.getPublicKey(privateKey);
// publicKey: "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef" // publicKey: "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"
``` ```
@@ -65,7 +71,7 @@ const publicKey = Keys.getPublicKey(privateKey);
```typescript ```typescript
// 1. Build the event structure // 1. Build the event structure
const event: EventData = { const event: Event = {
pubkey: publicKey, pubkey: publicKey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 1, kind: 1,
@@ -79,11 +85,11 @@ const event: EventData = {
}; };
// 2. Compute the event ID // 2. Compute the event ID
const id = Event.getID(event); const id = events.getID(event);
event.id = id; event.id = id;
// 3. Sign the event // 3. Sign the event
const sig = Event.sign(id, privateKey); const sig = events.sign(id, privateKey);
event.sig = sig; event.sig = sig;
``` ```
@@ -91,13 +97,13 @@ event.sig = sig;
```typescript ```typescript
// Returns canonical JSON: [0, pubkey, created_at, kind, tags, content] // Returns canonical JSON: [0, pubkey, created_at, kind, tags, content]
const serialized = Event.serialize(event); const serialized = events.serialize(event);
``` ```
#### Compute event ID manually #### Compute event ID manually
```typescript ```typescript
const id = Event.getID(event); const id = events.getID(event);
// Returns lowercase hex SHA-256 hash of serialized form // Returns lowercase hex SHA-256 hash of serialized form
``` ```
@@ -110,7 +116,7 @@ const id = Event.getID(event);
```typescript ```typescript
// Checks structure, ID computation, and signature // Checks structure, ID computation, and signature
try { try {
Event.validate(event); events.validate(event);
} catch (err) { } catch (err) {
console.log(`Invalid event: ${err.message}`); console.log(`Invalid event: ${err.message}`);
} }
@@ -121,21 +127,21 @@ try {
```typescript ```typescript
// Check field formats and lengths // Check field formats and lengths
try { try {
Event.validateStructure(event); events.validateStructure(event);
} catch (err) { } catch (err) {
console.log(`Malformed structure: ${err.message}`); console.log(`Malformed structure: ${err.message}`);
} }
// Verify ID matches computed hash // Verify ID matches computed hash
try { try {
Event.validateID(event); events.validateID(event);
} catch (err) { } catch (err) {
console.log(`ID mismatch: ${err.message}`); console.log(`ID mismatch: ${err.message}`);
} }
// Verify cryptographic signature // Verify cryptographic signature
try { try {
Event.validateSignature(event); events.validateSignature(event);
} catch (err) { } catch (err) {
console.log(`Invalid signature: ${err.message}`); console.log(`Invalid signature: ${err.message}`);
} }
@@ -148,18 +154,18 @@ try {
#### Marshal event to JSON #### Marshal event to JSON
```typescript ```typescript
const jsonString = JSON.stringify(Event.toJSON(event)); const jsonString = JSON.stringify(events.toJSON(event));
// Standard JSON.stringify works with Event.toJSON() // Standard JSON.stringify works with events.toJSON()
``` ```
#### Unmarshal event from JSON #### Unmarshal event from JSON
```typescript ```typescript
const event = Event.fromJSON(JSON.parse(jsonString)); const event = events.fromJSON(JSON.parse(jsonString));
// Validate after unmarshaling // Validate after unmarshaling
try { try {
Event.validate(event); events.validate(event);
} catch (err) { } catch (err) {
console.log(`Received invalid event: ${err.message}`); console.log(`Received invalid event: ${err.message}`);
} }
@@ -175,7 +181,7 @@ try {
const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60); const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60);
const limit = 50; const limit = 50;
const filter: FilterData = { const filter: Filter = {
ids: ["abc123", "def456"], // Prefix match ids: ["abc123", "def456"], // Prefix match
authors: ["cfa87f35"], // Prefix match authors: ["cfa87f35"], // Prefix match
kinds: [1, 6, 7], kinds: [1, 6, 7],
@@ -187,7 +193,7 @@ const filter: FilterData = {
#### Filter with tag conditions #### Filter with tag conditions
```typescript ```typescript
const filter: FilterData = { const filter: Filter = {
kinds: [1], kinds: [1],
tags: { tags: {
e: ["5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"], e: ["5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"],
@@ -201,7 +207,7 @@ const filter: FilterData = {
```typescript ```typescript
// Extensions allow arbitrary JSON fields beyond the standard filter spec. // Extensions allow arbitrary JSON fields beyond the standard filter spec.
// For example, this is how to implement non-standard filters like 'search'. // For example, this is how to implement non-standard filters like 'search'.
const filter: FilterData = { const filter: Filter = {
kinds: [1], kinds: [1],
extensions: { extensions: {
search: "bitcoin", search: "bitcoin",
@@ -219,12 +225,12 @@ const filter: FilterData = {
#### Match single event #### Match single event
```typescript ```typescript
const filter: FilterData = { const filter: Filter = {
authors: ["cfa87f35"], authors: ["cfa87f35"],
kinds: [1], kinds: [1],
}; };
if (Filter.matches(filter, event)) { if (filters.matches(filter, event)) {
// Event satisfies all filter conditions // Event satisfies all filter conditions
} }
``` ```
@@ -233,7 +239,7 @@ if (Filter.matches(filter, event)) {
```typescript ```typescript
const since = Math.floor(Date.now() / 1000) - (60 * 60); const since = Math.floor(Date.now() / 1000) - (60 * 60);
const filter: FilterData = { const filter: Filter = {
kinds: [1], kinds: [1],
since: since, since: since,
tags: { tags: {
@@ -241,7 +247,7 @@ const filter: FilterData = {
}, },
}; };
const matches = events.filter(event => Filter.matches(filter, event)); const matches = eventCollection.filter(event => filters.matches(filter, event));
``` ```
--- ---
@@ -251,7 +257,7 @@ const matches = events.filter(event => Filter.matches(filter, event));
#### Marshal filter to JSON #### Marshal filter to JSON
```typescript ```typescript
const filter: FilterData = { const filter: Filter = {
ids: ["abc123"], ids: ["abc123"],
kinds: [1], kinds: [1],
tags: { tags: {
@@ -262,7 +268,7 @@ const filter: FilterData = {
}, },
}; };
const jsonString = JSON.stringify(Filter.toJSON(filter)); const jsonString = JSON.stringify(filters.toJSON(filter));
// Result: {"ids":["abc123"],"kinds":[1],"#e":["event-id"],"search":"nostr"} // Result: {"ids":["abc123"],"kinds":[1],"#e":["event-id"],"search":"nostr"}
``` ```
@@ -277,7 +283,7 @@ const jsonData = `{
"search": "bitcoin" "search": "bitcoin"
}`; }`;
const filter = Filter.fromJSON(JSON.parse(jsonData)); const filter = filters.fromJSON(JSON.parse(jsonData));
// Standard fields populated: authors, kinds, since // Standard fields populated: authors, kinds, since
// Tag filters populated: tags.e = ["abc123"] // Tag filters populated: tags.e = ["abc123"]
@@ -299,7 +305,7 @@ During marshaling, extensions merge into the output JSON. During unmarshaling, u
Example implementing search filter: Example implementing search filter:
```typescript ```typescript
const filter: FilterData = { const filter: Filter = {
kinds: [1], kinds: [1],
extensions: { extensions: {
search: "bitcoin", search: "bitcoin",

View File

@@ -1,13 +1,27 @@
{ {
"name": "@wisehodl/roots", "name": "@wisehodl/roots",
"version": "0.1.0", "version": "0.2.0",
"description": "A minimal Nostr protocol library for typescript", "description": "A minimal Nostr protocol library for typescript",
"types": "./dist/types.d.ts",
"main": "./dist/index.js",
"exports": { "exports": {
".": { "./events": {
"import": "./dist/index.js", "import": "./dist/events/index.js",
"types": "./dist/index.d.ts" "types": "./dist/events/index.d.ts"
},
"./filters": {
"import": "./dist/filters/index.js",
"types": "./dist/filters/index.d.ts"
},
"./keys": {
"import": "./dist/keys/index.js",
"types": "./dist/keys/index.d.ts"
},
"./constants": {
"import": "./dist/constants/index.js",
"types": "./dist/constants/index.d.ts"
},
"./errors": {
"import": "./dist/errors/index.js",
"types": "./dist/errors/index.d.ts"
} }
}, },
"files": [ "files": [

View File

@@ -1,11 +0,0 @@
import { EventJSON } from "./event_json";
import { EventID } from "./id";
import { Sign } from "./sign";
import { Validate } from "./validate";
export const Event = {
...EventID,
...Sign,
...Validate,
...EventJSON,
};

20
src/events/event.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Tag represents a single tag within an event as an array of strings.
* The first element identifies the tag name, the second contains the value,
* and subsequent elements are optional.
*/
export type Tag = string[];
/**
* Event represents a Nostr protocol event with its seven required fields.
* All fields must be present for a valid event.
*/
export interface Event {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: Tag[];
content: string;
sig: string;
}

View File

@@ -1,24 +1,24 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { EventJSON } from "./event_json"; import { testEvent, testEventJSON, testPK } from "../util.test";
import type { EventData } from "./types"; import type { Event } from "./event";
import { testEvent, testEventJSON, testPK } from "./util.test"; import { fromJSON, toJSON } from "./event_json";
import { Validate } from "./validate"; import { validate } from "./validate";
describe("Event JSON", () => { describe("Event JSON", () => {
test("unmarshal event JSON", () => { test("unmarshal event JSON", () => {
const event = EventJSON.fromJSON(JSON.parse(testEventJSON)); const event = fromJSON(JSON.parse(testEventJSON));
expect(() => Validate.validate(event)).not.toThrow(); expect(() => validate(event)).not.toThrow();
expectEqualEvents(event, testEvent); expectEqualEvents(event, testEvent);
}); });
test("marshal event JSON", () => { test("marshal event JSON", () => {
const eventJSON = JSON.stringify(EventJSON.toJSON(testEvent)); const eventJSON = JSON.stringify(toJSON(testEvent));
expect(eventJSON).toBe(testEventJSON); expect(eventJSON).toBe(testEventJSON);
}); });
test("event JSON round trip", () => { test("event JSON round trip", () => {
const event: EventData = { const event: Event = {
id: "86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad", id: "86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad",
pubkey: testPK, pubkey: testPK,
created_at: 1760740551, created_at: 1760740551,
@@ -34,18 +34,18 @@ describe("Event JSON", () => {
const expectedJSON = `{"id":"86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":[["a","value"],["b","value","optional"],["name","value","optional","optional"]],"content":"hello world","sig":"c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557"}`; const expectedJSON = `{"id":"86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad","pubkey":"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef","created_at":1760740551,"kind":1,"tags":[["a","value"],["b","value","optional"],["name","value","optional","optional"]],"content":"hello world","sig":"c05fe02a9c082ff56aad2b16b5347498a21665f02f050ba086dbe6bd593c8cd448505d2831d1c0340acc1793eaf89b7c0cb21bb696c71da6b8d6b857702bb557"}`;
expect(() => Validate.validate(event)).not.toThrow(); expect(() => validate(event)).not.toThrow();
const eventJSON = JSON.stringify(EventJSON.toJSON(event)); const eventJSON = JSON.stringify(toJSON(event));
expect(eventJSON).toBe(expectedJSON); expect(eventJSON).toBe(expectedJSON);
const unmarshalledEvent = EventJSON.fromJSON(JSON.parse(eventJSON)); const unmarshalledEvent = fromJSON(JSON.parse(eventJSON));
expect(() => Validate.validate(unmarshalledEvent)).not.toThrow(); expect(() => validate(unmarshalledEvent)).not.toThrow();
expectEqualEvents(unmarshalledEvent, event); expectEqualEvents(unmarshalledEvent, event);
}); });
}); });
function expectEqualEvents(got: EventData, want: EventData): void { function expectEqualEvents(got: Event, want: Event): void {
expect(got.id).toBe(want.id); expect(got.id).toBe(want.id);
expect(got.pubkey).toBe(want.pubkey); expect(got.pubkey).toBe(want.pubkey);
expect(got.created_at).toBe(want.created_at); expect(got.created_at).toBe(want.created_at);

View File

@@ -1,11 +1,11 @@
import type { EventData } from "./types"; import type { Event } from "./event";
/** /**
* Converts an event to a plain object suitable for JSON.stringify(). * Converts an event to a plain object suitable for JSON.stringify().
* @param event - Event to convert * @param event - Event to convert
* @returns Plain object matching JSON structure * @returns Plain object matching JSON structure
*/ */
function toJSON(event: EventData): object { export function toJSON(event: Event): object {
return { return {
id: event.id, id: event.id,
pubkey: event.pubkey, pubkey: event.pubkey,
@@ -22,7 +22,7 @@ function toJSON(event: EventData): object {
* @param json - Parsed JSON object * @param json - Parsed JSON object
* @returns Event instance * @returns Event instance
*/ */
function fromJSON(json: any): EventData { export function fromJSON(json: any): Event {
return { return {
id: json.id || "", id: json.id || "",
pubkey: json.pubkey || "", pubkey: json.pubkey || "",
@@ -33,8 +33,3 @@ function fromJSON(json: any): EventData {
sig: json.sig || "", sig: json.sig || "",
}; };
} }
export const EventJSON = {
toJSON,
fromJSON,
};

View File

@@ -1,12 +1,12 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { EventID } from "./id"; import { testEvent, testPK } from "../util.test";
import type { EventData } from "./types"; import type { Event } from "./event";
import { testEvent, testPK } from "./util.test"; import { getID } from "./id";
interface IDTestCase { interface IDTestCase {
name: string; name: string;
event: EventData; event: Event;
expected: string; expected: string;
} }
@@ -215,7 +215,7 @@ const idTestCases: IDTestCase[] = [
describe("EventID.getID", () => { describe("EventID.getID", () => {
test.each(idTestCases)("$name", ({ event, expected }) => { test.each(idTestCases)("$name", ({ event, expected }) => {
const actual = EventID.getID(event); const actual = getID(event);
expect(actual).toBe(expected); expect(actual).toBe(expected);
}); });
}); });

View File

@@ -1,7 +1,7 @@
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex } from "@noble/hashes/utils.js"; import { bytesToHex } from "@noble/hashes/utils.js";
import type { EventData } from "./types"; import type { Event } from "./event";
/** /**
* Serializes an event into canonical JSON array format for ID computation. * Serializes an event into canonical JSON array format for ID computation.
@@ -9,7 +9,7 @@ import type { EventData } from "./types";
* @param event - Event to serialize * @param event - Event to serialize
* @returns Canonical JSON string * @returns Canonical JSON string
*/ */
function serialize(event: EventData): string { export function serialize(event: Event): string {
const serialized = [ const serialized = [
0, 0,
event.pubkey, event.pubkey,
@@ -26,13 +26,8 @@ function serialize(event: EventData): string {
* @param event - Event to compute ID for * @param event - Event to compute ID for
* @returns 64-character lowercase hexadecimal event ID * @returns 64-character lowercase hexadecimal event ID
*/ */
function getID(event: EventData): string { export function getID(event: Event): string {
const serialized = serialize(event); const serialized = serialize(event);
const hash = sha256(new TextEncoder().encode(serialized)); const hash = sha256(new TextEncoder().encode(serialized));
return bytesToHex(hash); return bytesToHex(hash);
} }
export const EventID = {
serialize,
getID,
};

5
src/events/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from "./event";
export * from "./event_json";
export * from "./id";
export * from "./sign";
export * from "./validate";

View File

@@ -1,22 +1,22 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { Sign } from "./sign"; import { testEvent, testSK } from "../util.test";
import { testEvent, testSK } from "./util.test"; import { sign } from "./sign";
describe("Sign.sign", () => { describe("sign", () => {
test("produces correct signature", () => { test("produces correct signature", () => {
const signature = Sign.sign(testEvent.id, testSK); const signature = sign(testEvent.id, testSK);
expect(signature).toBe(testEvent.sig); expect(signature).toBe(testEvent.sig);
}); });
test("throws on invalid event ID", () => { test("throws on invalid event ID", () => {
expect(() => Sign.sign("thisisabadeventid", testSK)).toThrow( expect(() => sign("thisisabadeventid", testSK)).toThrow(
/hex string expected,.*/, /hex string expected,.*/,
); );
}); });
test("throws on invalid private key", () => { test("throws on invalid private key", () => {
expect(() => Sign.sign(testEvent.id, "thisisabadsecretkey")).toThrow( expect(() => sign(testEvent.id, "thisisabadsecretkey")).toThrow(
/hex string expected,.*/, /hex string expected,.*/,
); );
}); });

View File

@@ -2,7 +2,7 @@ import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { schnorr } from "@noble/secp256k1"; import { schnorr } from "@noble/secp256k1";
import "./crypto_init"; import "../crypto_init";
/** /**
* Generates a Schnorr signature for the given event ID using the provided private key. * Generates a Schnorr signature for the given event ID using the provided private key.
@@ -12,7 +12,7 @@ import "./crypto_init";
* @throws {MalformedIDError} If event ID is not 64 hex characters * @throws {MalformedIDError} If event ID is not 64 hex characters
* @throws {MalformedPrivKeyError} If private key is not 64 lowercase hex characters * @throws {MalformedPrivKeyError} If private key is not 64 lowercase hex characters
*/ */
function sign(eventID: string, privateKey: string): string { export function sign(eventID: string, privateKey: string): string {
const privateKeyBytes = hexToBytes(privateKey); const privateKeyBytes = hexToBytes(privateKey);
const idBytes = hexToBytes(eventID); const idBytes = hexToBytes(eventID);
@@ -20,7 +20,3 @@ function sign(eventID: string, privateKey: string): string {
const signature = schnorr.sign(idBytes, privateKeyBytes, auxRand); const signature = schnorr.sign(idBytes, privateKeyBytes, auxRand);
return bytesToHex(signature); return bytesToHex(signature);
} }
export const Sign = {
sign,
};

View File

@@ -1,12 +1,17 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { EventData } from "./types"; import { testEvent, testPK } from "../util.test";
import { testEvent, testPK } from "./util.test"; import type { Event } from "./event";
import { Validate } from "./validate"; import {
validate,
validateID,
validateSignature,
validateStructure,
} from "./validate";
interface ValidateEventTestCase { interface ValidateEventTestCase {
name: string; name: string;
event: EventData; event: Event;
expectedError: string; expectedError: string;
} }
@@ -114,33 +119,31 @@ const structureTestCases: ValidateEventTestCase[] = [
describe("EventValidation.validateStructure", () => { describe("EventValidation.validateStructure", () => {
test.each(structureTestCases)("$name", ({ event, expectedError }) => { test.each(structureTestCases)("$name", ({ event, expectedError }) => {
expect(() => Validate.validateStructure(event)).toThrow(expectedError); expect(() => validateStructure(event)).toThrow(expectedError);
}); });
}); });
describe("EventValidation.validateID", () => { describe("EventValidation.validateID", () => {
test("detects ID mismatch", () => { test("detects ID mismatch", () => {
const event: EventData = { const event: Event = {
...testEvent, ...testEvent,
id: "7f661c2a3c1ed67dc959d6cd968d743d5e6e334313df44724bca939e2aa42c9e", id: "7f661c2a3c1ed67dc959d6cd968d743d5e6e334313df44724bca939e2aa42c9e",
}; };
expect(() => Validate.validateID(event)).toThrow( expect(() => validateID(event)).toThrow("does not match computed id");
"does not match computed id",
);
}); });
}); });
describe("EventValidation.validateSignature", () => { describe("EventValidation.validateSignature", () => {
test("accepts valid signature", () => { test("accepts valid signature", () => {
expect(() => Validate.validateSignature(testEvent)).not.toThrow(); expect(() => validateSignature(testEvent)).not.toThrow();
}); });
test("rejects invalid signature", () => { test("rejects invalid signature", () => {
const event: EventData = { const event: Event = {
...testEvent, ...testEvent,
sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482", sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482",
}; };
expect(() => Validate.validateSignature(event)).toThrow( expect(() => validateSignature(event)).toThrow(
"event signature is invalid", "event signature is invalid",
); );
}); });
@@ -196,15 +199,15 @@ describe("EventValidation.validateSignature - malformed inputs", () => {
test.each(validateSignatureTestCases)( test.each(validateSignatureTestCases)(
"$name", "$name",
({ id, sig, pubkey, expectedError }) => { ({ id, sig, pubkey, expectedError }) => {
const event: EventData = { ...testEvent, id, sig, pubkey }; const event: Event = { ...testEvent, id, sig, pubkey };
expect(() => Validate.validateSignature(event)).toThrow(expectedError); expect(() => validateSignature(event)).toThrow(expectedError);
}, },
); );
}); });
describe("EventValidation.validate", () => { describe("EventValidation.validate", () => {
test("validates complete event", () => { test("validates complete event", () => {
const event: EventData = { const event: Event = {
id: "c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400", id: "c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400",
pubkey: testPK, pubkey: testPK,
created_at: testEvent.created_at, created_at: testEvent.created_at,
@@ -216,6 +219,6 @@ describe("EventValidation.validate", () => {
content: "valid event", content: "valid event",
sig: "668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14", sig: "668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14",
}; };
expect(() => Validate.validate(event)).not.toThrow(); expect(() => validate(event)).not.toThrow();
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { hexToBytes } from "@noble/hashes/utils.js"; import { hexToBytes } from "@noble/hashes/utils.js";
import { schnorr } from "@noble/secp256k1"; import { schnorr } from "@noble/secp256k1";
import { HEX_64_PATTERN, HEX_128_PATTERN } from "./constants"; import { HEX_64_PATTERN, HEX_128_PATTERN } from "../constants";
import "./crypto_init"; import "../crypto_init";
import { import {
FailedIDCompError, FailedIDCompError,
InvalidSigError, InvalidSigError,
@@ -11,9 +11,9 @@ import {
MalformedSigError, MalformedSigError,
MalformedTagError, MalformedTagError,
NoEventIDError, NoEventIDError,
} from "./errors"; } from "../errors";
import { EventID } from "./id"; import type { Event } from "./event";
import type { EventData } from "./types"; import { getID } from "./id";
/** /**
* Checks event field formats and lengths conform to protocol specification. * Checks event field formats and lengths conform to protocol specification.
@@ -22,7 +22,7 @@ import type { EventData } from "./types";
* @throws {MalformedSigError} If sig is not 128 hex characters * @throws {MalformedSigError} If sig is not 128 hex characters
* @throws {MalformedTagError} If any tag has fewer than 2 elements * @throws {MalformedTagError} If any tag has fewer than 2 elements
*/ */
function validateStructure(event: EventData): void { export function validateStructure(event: Event): void {
if (!HEX_64_PATTERN.test(event.pubkey)) { if (!HEX_64_PATTERN.test(event.pubkey)) {
throw new MalformedPubKeyError(); throw new MalformedPubKeyError();
} }
@@ -48,10 +48,10 @@ function validateStructure(event: EventData): void {
* @throws {NoEventIDError} If event.id is empty * @throws {NoEventIDError} If event.id is empty
* @throws {Error} If computed ID does not match stored ID * @throws {Error} If computed ID does not match stored ID
*/ */
function validateID(event: EventData): void { export function validateID(event: Event): void {
let computedID: string; let computedID: string;
try { try {
computedID = EventID.getID(event); computedID = getID(event);
} catch (err) { } catch (err) {
throw new FailedIDCompError(); throw new FailedIDCompError();
} }
@@ -71,7 +71,7 @@ function validateID(event: EventData): void {
* Verifies the cryptographic signature using Schnorr verification. * Verifies the cryptographic signature using Schnorr verification.
* @throws {InvalidSigError} If signature verification fails * @throws {InvalidSigError} If signature verification fails
*/ */
function validateSignature(event: EventData): void { export function validateSignature(event: Event): void {
const idBytes = hexToBytes(event.id); const idBytes = hexToBytes(event.id);
const sigBytes = hexToBytes(event.sig); const sigBytes = hexToBytes(event.sig);
const pubkeyBytes = hexToBytes(event.pubkey); const pubkeyBytes = hexToBytes(event.pubkey);
@@ -87,15 +87,8 @@ function validateSignature(event: EventData): void {
* Performs complete event validation: structure, ID, and signature. * Performs complete event validation: structure, ID, and signature.
* @throws First validation error encountered * @throws First validation error encountered
*/ */
function validate(event: EventData): void { export function validate(event: Event): void {
validateStructure(event); validateStructure(event);
validateID(event); validateID(event);
validateSignature(event); validateSignature(event);
} }
export const Validate = {
validate,
validateStructure,
validateID,
validateSignature,
};

View File

@@ -1,3 +0,0 @@
import { test } from "vitest";
test("placeholder", () => {});

View File

@@ -1,7 +0,0 @@
import { FilterJSON } from "./filter_json";
import { FilterMatch } from "./filter_match";
export const Filter = {
...FilterMatch,
...FilterJSON,
};

View File

@@ -1,24 +1,3 @@
/**
* Tag represents a single tag within an event as an array of strings.
* The first element identifies the tag name, the second contains the value,
* and subsequent elements are optional.
*/
export type Tag = string[];
/**
* EventData represents a Nostr protocol event with its seven required fields.
* All fields must be present for a valid event.
*/
export interface EventData {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: Tag[];
content: string;
sig: string;
}
/** /**
* TagFilters maps tag names to arrays of values for tag-based filtering. * TagFilters maps tag names to arrays of values for tag-based filtering.
* Keys correspond to tag names without the "#" prefix. * Keys correspond to tag names without the "#" prefix.
@@ -36,10 +15,10 @@ export interface FilterExtensions {
} }
/** /**
* FilterData defines subscription criteria for events. * Filter defines subscription criteria for events.
* All conditions within a filter are applied with AND logic. * All conditions within a filter are applied with AND logic.
*/ */
export interface FilterData { export interface Filter {
ids?: string[] | null; ids?: string[] | null;
authors?: string[] | null; authors?: string[] | null;
kinds?: number[] | null; kinds?: number[] | null;

View File

@@ -1,23 +1,23 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { FilterJSON } from "./filter_json"; import type { Filter } from "./filter";
import type { FilterData } from "./types"; import { fromJSON, toJSON } from "./filter_json";
interface FilterMarshalTestCase { interface FilterMarshalTestCase {
name: string; name: string;
filter: FilterData; filter: Filter;
expected: string; expected: string;
} }
interface FilterUnmarshalTestCase { interface FilterUnmarshalTestCase {
name: string; name: string;
input: string; input: string;
expected: FilterData; expected: Filter;
} }
interface FilterRoundTripTestCase { interface FilterRoundTripTestCase {
name: string; name: string;
filter: FilterData; filter: Filter;
} }
const marshalTestCases: FilterMarshalTestCase[] = [ const marshalTestCases: FilterMarshalTestCase[] = [
@@ -447,31 +447,31 @@ const roundTripTestCases: FilterRoundTripTestCase[] = [
}, },
]; ];
describe("FilterJSON.toJSON", () => { describe("toJSON", () => {
test.each(marshalTestCases)("$name", ({ filter, expected }) => { test.each(marshalTestCases)("$name", ({ filter, expected }) => {
const result = JSON.stringify(FilterJSON.toJSON(filter)); const result = JSON.stringify(toJSON(filter));
const expectedObj = JSON.parse(expected); const expectedObj = JSON.parse(expected);
const actualObj = JSON.parse(result); const actualObj = JSON.parse(result);
expect(actualObj).toEqual(expectedObj); expect(actualObj).toEqual(expectedObj);
}); });
}); });
describe("FilterJSON.fromJSON", () => { describe("fromJSON", () => {
test.each(unmarshalTestCases)("$name", ({ input, expected }) => { test.each(unmarshalTestCases)("$name", ({ input, expected }) => {
const result = FilterJSON.fromJSON(JSON.parse(input)); const result = fromJSON(JSON.parse(input));
expectEqualFilters(result, expected); expectEqualFilters(result, expected);
}); });
}); });
describe("FilterJSON round trip", () => { describe("FilterJSON round trip", () => {
test.each(roundTripTestCases)("$name", ({ filter }) => { test.each(roundTripTestCases)("$name", ({ filter }) => {
const jsonBytes = JSON.stringify(FilterJSON.toJSON(filter)); const jsonBytes = JSON.stringify(toJSON(filter));
const result = FilterJSON.fromJSON(JSON.parse(jsonBytes)); const result = fromJSON(JSON.parse(jsonBytes));
expectEqualFilters(result, filter); expectEqualFilters(result, filter);
}); });
}); });
function expectEqualFilters(got: FilterData, want: FilterData): void { function expectEqualFilters(got: Filter, want: Filter): void {
expect(got.ids).toEqual(want.ids); expect(got.ids).toEqual(want.ids);
expect(got.authors).toEqual(want.authors); expect(got.authors).toEqual(want.authors);
expect(got.kinds).toEqual(want.kinds); expect(got.kinds).toEqual(want.kinds);

View File

@@ -1,10 +1,10 @@
import type { FilterData, FilterExtensions, TagFilters } from "./types"; import type { Filter, TagFilters } from "./filter";
/** /**
* Converts a filter to a plain object suitable for JSON.stringify(). * Converts a filter to a plain object suitable for JSON.stringify().
* Merges standard fields, tag filters (prefixed with #), and extensions. * Merges standard fields, tag filters (prefixed with #), and extensions.
*/ */
function toJSON(filter: FilterData): object { export function toJSON(filter: Filter): object {
const output: Record<string, any> = {}; const output: Record<string, any> = {};
// Standard fields // Standard fields
@@ -46,8 +46,8 @@ function toJSON(filter: FilterData): object {
* Parses a filter from JSON data. * Parses a filter from JSON data.
* Separates standard fields, tag filters (keys starting with #), and extensions. * Separates standard fields, tag filters (keys starting with #), and extensions.
*/ */
function fromJSON(json: any): FilterData { export function fromJSON(json: any): Filter {
const filter: FilterData = {}; const filter: Filter = {};
const remaining: Record<string, any> = { ...json }; const remaining: Record<string, any> = { ...json };
// Extract standard fields // Extract standard fields
@@ -96,8 +96,3 @@ function fromJSON(json: any): FilterData {
return filter; return filter;
} }
export const FilterJSON = {
toJSON,
fromJSON,
};

View File

@@ -1,16 +1,17 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { FilterMatch } from "./filter_match"; import type { Event } from "../events";
import type { EventData, FilterData } from "./types"; import type { Filter } from "./filter";
import { matches } from "./filter_match";
const testEvents: EventData[] = JSON.parse( const testEvents: Event[] = JSON.parse(
readFileSync("src/testdata/test_events.json", "utf-8"), readFileSync("src/testdata/test_events.json", "utf-8"),
); );
interface FilterTestCase { interface FilterTestCase {
name: string; name: string;
filter: FilterData; filter: Filter;
expectedIDs: string[]; expectedIDs: string[];
} }
@@ -365,19 +366,19 @@ const filterTestCases: FilterTestCase[] = [
}, },
]; ];
describe("FilterMatch.matches", () => { describe("matches", () => {
test.each(filterTestCases)("$name", ({ filter, expectedIDs }) => { test.each(filterTestCases)("$name", ({ filter, expectedIDs }) => {
const actualIDs = testEvents const actualIDs = testEvents
.filter((event) => FilterMatch.matches(filter, event)) .filter((event) => matches(filter, event))
.map((event) => event.id.slice(0, 8)); .map((event) => event.id.slice(0, 8));
expect(actualIDs).toEqual(expectedIDs); expect(actualIDs).toEqual(expectedIDs);
}); });
}); });
describe("FilterMatch.matches - skip malformed tags", () => { describe("matches - skip malformed tags", () => {
test("skips malformed tags during tag matching", () => { test("skips malformed tags during tag matching", () => {
const event: EventData = { const event: Event = {
id: "test", id: "test",
pubkey: "test", pubkey: "test",
created_at: 0, created_at: 0,
@@ -386,10 +387,10 @@ describe("FilterMatch.matches - skip malformed tags", () => {
content: "", content: "",
sig: "test", sig: "test",
}; };
const filter: FilterData = { const filter: Filter = {
tags: { valid: ["value"] }, tags: { valid: ["value"] },
}; };
expect(FilterMatch.matches(filter, event)).toBe(true); expect(matches(filter, event)).toBe(true);
}); });
}); });

View File

@@ -1,4 +1,5 @@
import type { EventData, FilterData, Tag, TagFilters } from "./types"; import type { Event, Tag } from "../events";
import type { Filter, TagFilters } from "./filter";
/** /**
* Returns true if candidate starts with any prefix in the list. * Returns true if candidate starts with any prefix in the list.
@@ -76,7 +77,7 @@ function matchesTags(eventTags: Tag[], tagFilters: TagFilters): boolean {
* Returns true if the event satisfies all filter conditions (AND logic). * Returns true if the event satisfies all filter conditions (AND logic).
* Does not account for custom extensions. * Does not account for custom extensions.
*/ */
function matches(filter: FilterData, event: EventData): boolean { export function matches(filter: Filter, event: Event): boolean {
// Check ID prefixes // Check ID prefixes
if (filter.ids && filter.ids.length > 0) { if (filter.ids && filter.ids.length > 0) {
if (!matchesPrefix(event.id, filter.ids)) { if (!matchesPrefix(event.id, filter.ids)) {
@@ -112,7 +113,3 @@ function matches(filter: FilterData, event: EventData): boolean {
return true; return true;
} }
export const FilterMatch = {
matches,
};

3
src/filters/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./filter";
export * from "./filter_json";
export * from "./filter_match";

View File

@@ -1,52 +0,0 @@
import { describe, expect, test } from "vitest";
import * as Roots from "./index";
describe("Package Exports", () => {
test("exports type definitions", () => {
// Types aren't runtime values, but we can check the module has them
expect(Roots).toBeDefined();
});
test("exports constants", () => {
expect(Roots.HEX_64_PATTERN).toBeDefined();
expect(Roots.HEX_128_PATTERN).toBeDefined();
});
test("exports error classes", () => {
expect(Roots.MalformedPubKeyError).toBeDefined();
expect(Roots.MalformedPrivKeyError).toBeDefined();
expect(Roots.MalformedIDError).toBeDefined();
expect(Roots.MalformedSigError).toBeDefined();
expect(Roots.MalformedTagError).toBeDefined();
expect(Roots.FailedIDCompError).toBeDefined();
expect(Roots.NoEventIDError).toBeDefined();
expect(Roots.InvalidSigError).toBeDefined();
});
test("exports Keys namespace", () => {
expect(Roots.Keys).toBeDefined();
expect(Roots.Keys.generatePrivateKey).toBeTypeOf("function");
expect(Roots.Keys.getPublicKey).toBeTypeOf("function");
});
test("exports Event namespace", () => {
expect(Roots.Event).toBeDefined();
expect(Roots.Event.serialize).toBeTypeOf("function");
expect(Roots.Event.getID).toBeTypeOf("function");
expect(Roots.Event.sign).toBeTypeOf("function");
expect(Roots.Event.validate).toBeTypeOf("function");
expect(Roots.Event.validateStructure).toBeTypeOf("function");
expect(Roots.Event.validateID).toBeTypeOf("function");
expect(Roots.Event.validateSignature).toBeTypeOf("function");
expect(Roots.Event.toJSON).toBeTypeOf("function");
expect(Roots.Event.fromJSON).toBeTypeOf("function");
});
test("exports Filter namespace", () => {
expect(Roots.Filter).toBeDefined();
expect(Roots.Filter.matches).toBeTypeOf("function");
expect(Roots.Filter.toJSON).toBeTypeOf("function");
expect(Roots.Filter.fromJSON).toBeTypeOf("function");
});
});

View File

@@ -1,6 +0,0 @@
export * from "./types";
export * from "./constants";
export * from "./errors";
export { Event } from "./event";
export { Filter } from "./filter";
export { Keys } from "./keys";

1
src/keys/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./keys";

View File

@@ -1,37 +1,35 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { HEX_64_PATTERN } from "./constants"; import { HEX_64_PATTERN } from "../constants";
import { Keys } from "./keys"; import { testPK, testSK } from "../util.test";
import { testPK, testSK } from "./util.test"; import { generatePrivateKey, getPublicKey } from "./keys";
describe("Keys.generatePrivate", () => { describe("generatePrivate", () => {
test("returns 64 hex characters", () => { test("returns 64 hex characters", () => {
const privateKey = Keys.generatePrivateKey(); const privateKey = generatePrivateKey();
expect(privateKey).toMatch(HEX_64_PATTERN); expect(privateKey).toMatch(HEX_64_PATTERN);
}); });
test("generates unique keys", () => { test("generates unique keys", () => {
const key1 = Keys.generatePrivateKey(); const key1 = generatePrivateKey();
const key2 = Keys.generatePrivateKey(); const key2 = generatePrivateKey();
expect(key1).not.toBe(key2); expect(key1).not.toBe(key2);
}); });
}); });
describe("Keys.getPublic", () => { describe("getPublic", () => {
test("derives correct public key", () => { test("derives correct public key", () => {
const publicKey = Keys.getPublicKey(testSK); const publicKey = getPublicKey(testSK);
expect(publicKey).toBe(testPK); expect(publicKey).toBe(testPK);
}); });
test("throws on invalid private key - too short", () => { test("throws on invalid private key - too short", () => {
expect(() => Keys.getPublicKey("abc123")).toThrow( expect(() => getPublicKey("abc123")).toThrow(/"secret key" expected.*/);
/"secret key" expected.*/,
);
}); });
test("throws on invalid private key - non-hex", () => { test("throws on invalid private key - non-hex", () => {
expect(() => expect(() =>
Keys.getPublicKey( getPublicKey(
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
), ),
).toThrow(/hex string expected,.*/); ).toThrow(/hex string expected,.*/);

View File

@@ -5,7 +5,7 @@ import { schnorr } from "@noble/secp256k1";
* Generates a new random secp256k1 private key. * Generates a new random secp256k1 private key.
* @returns 64-character lowercase hexadecimal string * @returns 64-character lowercase hexadecimal string
*/ */
function generatePrivateKey(): string { export function generatePrivateKey(): string {
const { secretKey } = schnorr.keygen(); const { secretKey } = schnorr.keygen();
return bytesToHex(secretKey); return bytesToHex(secretKey);
} }
@@ -16,14 +16,9 @@ function generatePrivateKey(): string {
* @returns 64-character lowercase hexadecimal public key (x-coordinate only) * @returns 64-character lowercase hexadecimal public key (x-coordinate only)
* @throws {MalformedPrivKeyError} If private key is not 64 lowercase hex characters * @throws {MalformedPrivKeyError} If private key is not 64 lowercase hex characters
*/ */
function getPublicKey(privateKey: string): string { export function getPublicKey(privateKey: string): string {
const privateKeyBytes = hexToBytes(privateKey); const privateKeyBytes = hexToBytes(privateKey);
const publicKeyBytes = schnorr.getPublicKey(privateKeyBytes); const publicKeyBytes = schnorr.getPublicKey(privateKeyBytes);
return bytesToHex(publicKeyBytes); return bytesToHex(publicKeyBytes);
} }
export const Keys = {
generatePrivateKey,
getPublicKey,
};

View File

@@ -1,6 +1,6 @@
import { test } from "vitest"; import { test } from "vitest";
import type { EventData } from "./types"; import type { Event } from "./events";
test("placeholder", () => {}); test("placeholder", () => {});
@@ -9,7 +9,7 @@ export const testSK =
export const testPK = export const testPK =
"cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"; "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef";
export const testEvent: EventData = { export const testEvent: Event = {
id: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", id: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad",
pubkey: testPK, pubkey: testPK,
created_at: 1760740551, created_at: 1760740551,