diff --git a/README.md b/README.md index 1c1cbee..cc55b31 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,14 @@ npm install @wisehodl/roots 2. Import it: ```typescript -import { Event, Filter, Keys } from '@wisehodl/roots'; -import type { EventData, FilterData } from '@wisehodl/roots'; +import * as events from '@wisehodl/roots/events'; +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 @@ -45,15 +51,15 @@ import type { EventData, FilterData } from '@wisehodl/roots'; #### Generate a new keypair ```typescript -const privateKey = Keys.generatePrivateKey(); -const publicKey = Keys.getPublicKey(privateKey); +const privateKey = keys.generatePrivateKey(); +const publicKey = keys.getPublicKey(privateKey); ``` #### Derive public key from existing private key ```typescript const privateKey = "f43a0435f69529f310bbd1d6263d2fbf0977f54bfe2310cc37ae5904b83bb167"; -const publicKey = Keys.getPublicKey(privateKey); +const publicKey = keys.getPublicKey(privateKey); // publicKey: "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef" ``` @@ -65,7 +71,7 @@ const publicKey = Keys.getPublicKey(privateKey); ```typescript // 1. Build the event structure -const event: EventData = { +const event: Event = { pubkey: publicKey, created_at: Math.floor(Date.now() / 1000), kind: 1, @@ -79,11 +85,11 @@ const event: EventData = { }; // 2. Compute the event ID -const id = Event.getID(event); +const id = events.getID(event); event.id = id; // 3. Sign the event -const sig = Event.sign(id, privateKey); +const sig = events.sign(id, privateKey); event.sig = sig; ``` @@ -91,13 +97,13 @@ event.sig = sig; ```typescript // Returns canonical JSON: [0, pubkey, created_at, kind, tags, content] -const serialized = Event.serialize(event); +const serialized = events.serialize(event); ``` #### Compute event ID manually ```typescript -const id = Event.getID(event); +const id = events.getID(event); // Returns lowercase hex SHA-256 hash of serialized form ``` @@ -110,7 +116,7 @@ const id = Event.getID(event); ```typescript // Checks structure, ID computation, and signature try { - Event.validate(event); + events.validate(event); } catch (err) { console.log(`Invalid event: ${err.message}`); } @@ -121,21 +127,21 @@ try { ```typescript // Check field formats and lengths try { - Event.validateStructure(event); + events.validateStructure(event); } catch (err) { console.log(`Malformed structure: ${err.message}`); } // Verify ID matches computed hash try { - Event.validateID(event); + events.validateID(event); } catch (err) { console.log(`ID mismatch: ${err.message}`); } // Verify cryptographic signature try { - Event.validateSignature(event); + events.validateSignature(event); } catch (err) { console.log(`Invalid signature: ${err.message}`); } @@ -148,18 +154,18 @@ try { #### Marshal event to JSON ```typescript -const jsonString = JSON.stringify(Event.toJSON(event)); -// Standard JSON.stringify works with Event.toJSON() +const jsonString = JSON.stringify(events.toJSON(event)); +// Standard JSON.stringify works with events.toJSON() ``` #### Unmarshal event from JSON ```typescript -const event = Event.fromJSON(JSON.parse(jsonString)); +const event = events.fromJSON(JSON.parse(jsonString)); // Validate after unmarshaling try { - Event.validate(event); + events.validate(event); } catch (err) { console.log(`Received invalid event: ${err.message}`); } @@ -175,7 +181,7 @@ try { const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60); const limit = 50; -const filter: FilterData = { +const filter: Filter = { ids: ["abc123", "def456"], // Prefix match authors: ["cfa87f35"], // Prefix match kinds: [1, 6, 7], @@ -187,7 +193,7 @@ const filter: FilterData = { #### Filter with tag conditions ```typescript -const filter: FilterData = { +const filter: Filter = { kinds: [1], tags: { e: ["5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"], @@ -201,7 +207,7 @@ const filter: FilterData = { ```typescript // Extensions allow arbitrary JSON fields beyond the standard filter spec. // For example, this is how to implement non-standard filters like 'search'. -const filter: FilterData = { +const filter: Filter = { kinds: [1], extensions: { search: "bitcoin", @@ -219,12 +225,12 @@ const filter: FilterData = { #### Match single event ```typescript -const filter: FilterData = { +const filter: Filter = { authors: ["cfa87f35"], kinds: [1], }; -if (Filter.matches(filter, event)) { +if (filters.matches(filter, event)) { // Event satisfies all filter conditions } ``` @@ -233,7 +239,7 @@ if (Filter.matches(filter, event)) { ```typescript const since = Math.floor(Date.now() / 1000) - (60 * 60); -const filter: FilterData = { +const filter: Filter = { kinds: [1], since: since, 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 ```typescript -const filter: FilterData = { +const filter: Filter = { ids: ["abc123"], kinds: [1], 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"} ``` @@ -277,7 +283,7 @@ const jsonData = `{ "search": "bitcoin" }`; -const filter = Filter.fromJSON(JSON.parse(jsonData)); +const filter = filters.fromJSON(JSON.parse(jsonData)); // Standard fields populated: authors, kinds, since // 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: ```typescript -const filter: FilterData = { +const filter: Filter = { kinds: [1], extensions: { search: "bitcoin", diff --git a/package.json b/package.json index d33ce5f..f9e8d8c 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,27 @@ { "name": "@wisehodl/roots", - "version": "0.1.0", + "version": "0.2.0", "description": "A minimal Nostr protocol library for typescript", - "types": "./dist/types.d.ts", - "main": "./dist/index.js", "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "./events": { + "import": "./dist/events/index.js", + "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": [ diff --git a/src/constants.ts b/src/constants/index.ts similarity index 100% rename from src/constants.ts rename to src/constants/index.ts diff --git a/src/errors.ts b/src/errors/index.ts similarity index 100% rename from src/errors.ts rename to src/errors/index.ts diff --git a/src/event.ts b/src/event.ts deleted file mode 100644 index 643225a..0000000 --- a/src/event.ts +++ /dev/null @@ -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, -}; diff --git a/src/events/event.ts b/src/events/event.ts new file mode 100644 index 0000000..2fea0c9 --- /dev/null +++ b/src/events/event.ts @@ -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; +} diff --git a/src/event_json.test.ts b/src/events/event_json.test.ts similarity index 68% rename from src/event_json.test.ts rename to src/events/event_json.test.ts index 31d609e..d0d6ec8 100644 --- a/src/event_json.test.ts +++ b/src/events/event_json.test.ts @@ -1,24 +1,24 @@ import { describe, expect, test } from "vitest"; -import { EventJSON } from "./event_json"; -import type { EventData } from "./types"; -import { testEvent, testEventJSON, testPK } from "./util.test"; -import { Validate } from "./validate"; +import { testEvent, testEventJSON, testPK } from "../util.test"; +import type { Event } from "./event"; +import { fromJSON, toJSON } from "./event_json"; +import { validate } from "./validate"; describe("Event JSON", () => { test("unmarshal event JSON", () => { - const event = EventJSON.fromJSON(JSON.parse(testEventJSON)); - expect(() => Validate.validate(event)).not.toThrow(); + const event = fromJSON(JSON.parse(testEventJSON)); + expect(() => validate(event)).not.toThrow(); expectEqualEvents(event, testEvent); }); test("marshal event JSON", () => { - const eventJSON = JSON.stringify(EventJSON.toJSON(testEvent)); + const eventJSON = JSON.stringify(toJSON(testEvent)); expect(eventJSON).toBe(testEventJSON); }); test("event JSON round trip", () => { - const event: EventData = { + const event: Event = { id: "86e856d0527dd08527498cd8afd8a7d296bde37e4757a8921f034f0b344df3ad", pubkey: testPK, 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"}`; - 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); - const unmarshalledEvent = EventJSON.fromJSON(JSON.parse(eventJSON)); - expect(() => Validate.validate(unmarshalledEvent)).not.toThrow(); + const unmarshalledEvent = fromJSON(JSON.parse(eventJSON)); + expect(() => validate(unmarshalledEvent)).not.toThrow(); 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.pubkey).toBe(want.pubkey); expect(got.created_at).toBe(want.created_at); diff --git a/src/event_json.ts b/src/events/event_json.ts similarity index 78% rename from src/event_json.ts rename to src/events/event_json.ts index 76fb281..0ff3fcc 100644 --- a/src/event_json.ts +++ b/src/events/event_json.ts @@ -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(). * @param event - Event to convert * @returns Plain object matching JSON structure */ -function toJSON(event: EventData): object { +export function toJSON(event: Event): object { return { id: event.id, pubkey: event.pubkey, @@ -22,7 +22,7 @@ function toJSON(event: EventData): object { * @param json - Parsed JSON object * @returns Event instance */ -function fromJSON(json: any): EventData { +export function fromJSON(json: any): Event { return { id: json.id || "", pubkey: json.pubkey || "", @@ -33,8 +33,3 @@ function fromJSON(json: any): EventData { sig: json.sig || "", }; } - -export const EventJSON = { - toJSON, - fromJSON, -}; diff --git a/src/id.test.ts b/src/events/id.test.ts similarity index 96% rename from src/id.test.ts rename to src/events/id.test.ts index 5497383..a9e0725 100644 --- a/src/id.test.ts +++ b/src/events/id.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "vitest"; -import { EventID } from "./id"; -import type { EventData } from "./types"; -import { testEvent, testPK } from "./util.test"; +import { testEvent, testPK } from "../util.test"; +import type { Event } from "./event"; +import { getID } from "./id"; interface IDTestCase { name: string; - event: EventData; + event: Event; expected: string; } @@ -215,7 +215,7 @@ const idTestCases: IDTestCase[] = [ describe("EventID.getID", () => { test.each(idTestCases)("$name", ({ event, expected }) => { - const actual = EventID.getID(event); + const actual = getID(event); expect(actual).toBe(expected); }); }); diff --git a/src/id.ts b/src/events/id.ts similarity index 81% rename from src/id.ts rename to src/events/id.ts index e2a4a49..7065ad2 100644 --- a/src/id.ts +++ b/src/events/id.ts @@ -1,7 +1,7 @@ import { sha256 } from "@noble/hashes/sha2.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. @@ -9,7 +9,7 @@ import type { EventData } from "./types"; * @param event - Event to serialize * @returns Canonical JSON string */ -function serialize(event: EventData): string { +export function serialize(event: Event): string { const serialized = [ 0, event.pubkey, @@ -26,13 +26,8 @@ function serialize(event: EventData): string { * @param event - Event to compute ID for * @returns 64-character lowercase hexadecimal event ID */ -function getID(event: EventData): string { +export function getID(event: Event): string { const serialized = serialize(event); const hash = sha256(new TextEncoder().encode(serialized)); return bytesToHex(hash); } - -export const EventID = { - serialize, - getID, -}; diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..da4b764 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,5 @@ +export * from "./event"; +export * from "./event_json"; +export * from "./id"; +export * from "./sign"; +export * from "./validate"; diff --git a/src/sign.test.ts b/src/events/sign.test.ts similarity index 52% rename from src/sign.test.ts rename to src/events/sign.test.ts index 35ab625..0352d23 100644 --- a/src/sign.test.ts +++ b/src/events/sign.test.ts @@ -1,22 +1,22 @@ 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", () => { - const signature = Sign.sign(testEvent.id, testSK); + const signature = sign(testEvent.id, testSK); expect(signature).toBe(testEvent.sig); }); test("throws on invalid event ID", () => { - expect(() => Sign.sign("thisisabadeventid", testSK)).toThrow( + expect(() => sign("thisisabadeventid", testSK)).toThrow( /hex string expected,.*/, ); }); test("throws on invalid private key", () => { - expect(() => Sign.sign(testEvent.id, "thisisabadsecretkey")).toThrow( + expect(() => sign(testEvent.id, "thisisabadsecretkey")).toThrow( /hex string expected,.*/, ); }); diff --git a/src/sign.ts b/src/events/sign.ts similarity index 87% rename from src/sign.ts rename to src/events/sign.ts index 0089ea2..e261ed5 100644 --- a/src/sign.ts +++ b/src/events/sign.ts @@ -2,7 +2,7 @@ import { sha256 } from "@noble/hashes/sha2.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; 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. @@ -12,7 +12,7 @@ import "./crypto_init"; * @throws {MalformedIDError} If event ID is not 64 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 idBytes = hexToBytes(eventID); @@ -20,7 +20,3 @@ function sign(eventID: string, privateKey: string): string { const signature = schnorr.sign(idBytes, privateKeyBytes, auxRand); return bytesToHex(signature); } - -export const Sign = { - sign, -}; diff --git a/src/validate.test.ts b/src/events/validate.test.ts similarity index 86% rename from src/validate.test.ts rename to src/events/validate.test.ts index 6f24421..31af23a 100644 --- a/src/validate.test.ts +++ b/src/events/validate.test.ts @@ -1,12 +1,17 @@ import { describe, expect, test } from "vitest"; -import type { EventData } from "./types"; -import { testEvent, testPK } from "./util.test"; -import { Validate } from "./validate"; +import { testEvent, testPK } from "../util.test"; +import type { Event } from "./event"; +import { + validate, + validateID, + validateSignature, + validateStructure, +} from "./validate"; interface ValidateEventTestCase { name: string; - event: EventData; + event: Event; expectedError: string; } @@ -114,33 +119,31 @@ const structureTestCases: ValidateEventTestCase[] = [ describe("EventValidation.validateStructure", () => { test.each(structureTestCases)("$name", ({ event, expectedError }) => { - expect(() => Validate.validateStructure(event)).toThrow(expectedError); + expect(() => validateStructure(event)).toThrow(expectedError); }); }); describe("EventValidation.validateID", () => { test("detects ID mismatch", () => { - const event: EventData = { + const event: Event = { ...testEvent, id: "7f661c2a3c1ed67dc959d6cd968d743d5e6e334313df44724bca939e2aa42c9e", }; - expect(() => Validate.validateID(event)).toThrow( - "does not match computed id", - ); + expect(() => validateID(event)).toThrow("does not match computed id"); }); }); describe("EventValidation.validateSignature", () => { test("accepts valid signature", () => { - expect(() => Validate.validateSignature(testEvent)).not.toThrow(); + expect(() => validateSignature(testEvent)).not.toThrow(); }); test("rejects invalid signature", () => { - const event: EventData = { + const event: Event = { ...testEvent, sig: "9e43cbcf7e828a21c53fa35371ee79bffbfd7a3063ae46fc05ec623dd3186667c57e3d006488015e19247df35eb41c61013e051aa87860e23fa5ffbd44120482", }; - expect(() => Validate.validateSignature(event)).toThrow( + expect(() => validateSignature(event)).toThrow( "event signature is invalid", ); }); @@ -196,15 +199,15 @@ describe("EventValidation.validateSignature - malformed inputs", () => { test.each(validateSignatureTestCases)( "$name", ({ id, sig, pubkey, expectedError }) => { - const event: EventData = { ...testEvent, id, sig, pubkey }; - expect(() => Validate.validateSignature(event)).toThrow(expectedError); + const event: Event = { ...testEvent, id, sig, pubkey }; + expect(() => validateSignature(event)).toThrow(expectedError); }, ); }); describe("EventValidation.validate", () => { test("validates complete event", () => { - const event: EventData = { + const event: Event = { id: "c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400", pubkey: testPK, created_at: testEvent.created_at, @@ -216,6 +219,6 @@ describe("EventValidation.validate", () => { content: "valid event", sig: "668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14", }; - expect(() => Validate.validate(event)).not.toThrow(); + expect(() => validate(event)).not.toThrow(); }); }); diff --git a/src/validate.ts b/src/events/validate.ts similarity index 80% rename from src/validate.ts rename to src/events/validate.ts index 4b21eab..149a575 100644 --- a/src/validate.ts +++ b/src/events/validate.ts @@ -1,8 +1,8 @@ import { hexToBytes } from "@noble/hashes/utils.js"; import { schnorr } from "@noble/secp256k1"; -import { HEX_64_PATTERN, HEX_128_PATTERN } from "./constants"; -import "./crypto_init"; +import { HEX_64_PATTERN, HEX_128_PATTERN } from "../constants"; +import "../crypto_init"; import { FailedIDCompError, InvalidSigError, @@ -11,9 +11,9 @@ import { MalformedSigError, MalformedTagError, NoEventIDError, -} from "./errors"; -import { EventID } from "./id"; -import type { EventData } from "./types"; +} from "../errors"; +import type { Event } from "./event"; +import { getID } from "./id"; /** * 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 {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)) { throw new MalformedPubKeyError(); } @@ -48,10 +48,10 @@ function validateStructure(event: EventData): void { * @throws {NoEventIDError} If event.id is empty * @throws {Error} If computed ID does not match stored ID */ -function validateID(event: EventData): void { +export function validateID(event: Event): void { let computedID: string; try { - computedID = EventID.getID(event); + computedID = getID(event); } catch (err) { throw new FailedIDCompError(); } @@ -71,7 +71,7 @@ function validateID(event: EventData): void { * Verifies the cryptographic signature using Schnorr verification. * @throws {InvalidSigError} If signature verification fails */ -function validateSignature(event: EventData): void { +export function validateSignature(event: Event): void { const idBytes = hexToBytes(event.id); const sigBytes = hexToBytes(event.sig); const pubkeyBytes = hexToBytes(event.pubkey); @@ -87,15 +87,8 @@ function validateSignature(event: EventData): void { * Performs complete event validation: structure, ID, and signature. * @throws First validation error encountered */ -function validate(event: EventData): void { +export function validate(event: Event): void { validateStructure(event); validateID(event); validateSignature(event); } - -export const Validate = { - validate, - validateStructure, - validateID, - validateSignature, -}; diff --git a/src/filter.test.ts b/src/filter.test.ts deleted file mode 100644 index 31d1451..0000000 --- a/src/filter.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { test } from "vitest"; - -test("placeholder", () => {}); diff --git a/src/filter.ts b/src/filter.ts deleted file mode 100644 index c399fab..0000000 --- a/src/filter.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FilterJSON } from "./filter_json"; -import { FilterMatch } from "./filter_match"; - -export const Filter = { - ...FilterMatch, - ...FilterJSON, -}; diff --git a/src/types.ts b/src/filters/filter.ts similarity index 55% rename from src/types.ts rename to src/filters/filter.ts index 17b626e..47253a1 100644 --- a/src/types.ts +++ b/src/filters/filter.ts @@ -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. * 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. */ -export interface FilterData { +export interface Filter { ids?: string[] | null; authors?: string[] | null; kinds?: number[] | null; diff --git a/src/filter_json.test.ts b/src/filters/filter_json.test.ts similarity index 95% rename from src/filter_json.test.ts rename to src/filters/filter_json.test.ts index 227db31..7751fc3 100644 --- a/src/filter_json.test.ts +++ b/src/filters/filter_json.test.ts @@ -1,23 +1,23 @@ import { describe, expect, test } from "vitest"; -import { FilterJSON } from "./filter_json"; -import type { FilterData } from "./types"; +import type { Filter } from "./filter"; +import { fromJSON, toJSON } from "./filter_json"; interface FilterMarshalTestCase { name: string; - filter: FilterData; + filter: Filter; expected: string; } interface FilterUnmarshalTestCase { name: string; input: string; - expected: FilterData; + expected: Filter; } interface FilterRoundTripTestCase { name: string; - filter: FilterData; + filter: Filter; } const marshalTestCases: FilterMarshalTestCase[] = [ @@ -447,31 +447,31 @@ const roundTripTestCases: FilterRoundTripTestCase[] = [ }, ]; -describe("FilterJSON.toJSON", () => { +describe("toJSON", () => { 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 actualObj = JSON.parse(result); expect(actualObj).toEqual(expectedObj); }); }); -describe("FilterJSON.fromJSON", () => { +describe("fromJSON", () => { test.each(unmarshalTestCases)("$name", ({ input, expected }) => { - const result = FilterJSON.fromJSON(JSON.parse(input)); + const result = fromJSON(JSON.parse(input)); expectEqualFilters(result, expected); }); }); describe("FilterJSON round trip", () => { test.each(roundTripTestCases)("$name", ({ filter }) => { - const jsonBytes = JSON.stringify(FilterJSON.toJSON(filter)); - const result = FilterJSON.fromJSON(JSON.parse(jsonBytes)); + const jsonBytes = JSON.stringify(toJSON(filter)); + const result = fromJSON(JSON.parse(jsonBytes)); 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.authors).toEqual(want.authors); expect(got.kinds).toEqual(want.kinds); diff --git a/src/filter_json.ts b/src/filters/filter_json.ts similarity index 91% rename from src/filter_json.ts rename to src/filters/filter_json.ts index ebf0236..38c709c 100644 --- a/src/filter_json.ts +++ b/src/filters/filter_json.ts @@ -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(). * Merges standard fields, tag filters (prefixed with #), and extensions. */ -function toJSON(filter: FilterData): object { +export function toJSON(filter: Filter): object { const output: Record = {}; // Standard fields @@ -46,8 +46,8 @@ function toJSON(filter: FilterData): object { * Parses a filter from JSON data. * Separates standard fields, tag filters (keys starting with #), and extensions. */ -function fromJSON(json: any): FilterData { - const filter: FilterData = {}; +export function fromJSON(json: any): Filter { + const filter: Filter = {}; const remaining: Record = { ...json }; // Extract standard fields @@ -96,8 +96,3 @@ function fromJSON(json: any): FilterData { return filter; } - -export const FilterJSON = { - toJSON, - fromJSON, -}; diff --git a/src/filter_match.test.ts b/src/filters/filter_match.test.ts similarity index 94% rename from src/filter_match.test.ts rename to src/filters/filter_match.test.ts index e6f0712..a4ead54 100644 --- a/src/filter_match.test.ts +++ b/src/filters/filter_match.test.ts @@ -1,16 +1,17 @@ import { readFileSync } from "fs"; import { describe, expect, test } from "vitest"; -import { FilterMatch } from "./filter_match"; -import type { EventData, FilterData } from "./types"; +import type { Event } from "../events"; +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"), ); interface FilterTestCase { name: string; - filter: FilterData; + filter: Filter; expectedIDs: string[]; } @@ -365,19 +366,19 @@ const filterTestCases: FilterTestCase[] = [ }, ]; -describe("FilterMatch.matches", () => { +describe("matches", () => { test.each(filterTestCases)("$name", ({ filter, expectedIDs }) => { const actualIDs = testEvents - .filter((event) => FilterMatch.matches(filter, event)) + .filter((event) => matches(filter, event)) .map((event) => event.id.slice(0, 8)); expect(actualIDs).toEqual(expectedIDs); }); }); -describe("FilterMatch.matches - skip malformed tags", () => { +describe("matches - skip malformed tags", () => { test("skips malformed tags during tag matching", () => { - const event: EventData = { + const event: Event = { id: "test", pubkey: "test", created_at: 0, @@ -386,10 +387,10 @@ describe("FilterMatch.matches - skip malformed tags", () => { content: "", sig: "test", }; - const filter: FilterData = { + const filter: Filter = { tags: { valid: ["value"] }, }; - expect(FilterMatch.matches(filter, event)).toBe(true); + expect(matches(filter, event)).toBe(true); }); }); diff --git a/src/filter_match.ts b/src/filters/filter_match.ts similarity index 93% rename from src/filter_match.ts rename to src/filters/filter_match.ts index c65bbad..e25ae5d 100644 --- a/src/filter_match.ts +++ b/src/filters/filter_match.ts @@ -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. @@ -76,7 +77,7 @@ function matchesTags(eventTags: Tag[], tagFilters: TagFilters): boolean { * Returns true if the event satisfies all filter conditions (AND logic). * Does not account for custom extensions. */ -function matches(filter: FilterData, event: EventData): boolean { +export function matches(filter: Filter, event: Event): boolean { // Check ID prefixes if (filter.ids && filter.ids.length > 0) { if (!matchesPrefix(event.id, filter.ids)) { @@ -112,7 +113,3 @@ function matches(filter: FilterData, event: EventData): boolean { return true; } - -export const FilterMatch = { - matches, -}; diff --git a/src/filters/index.ts b/src/filters/index.ts new file mode 100644 index 0000000..1018b57 --- /dev/null +++ b/src/filters/index.ts @@ -0,0 +1,3 @@ +export * from "./filter"; +export * from "./filter_json"; +export * from "./filter_match"; diff --git a/src/index.test.js b/src/index.test.js deleted file mode 100644 index 15ea7c6..0000000 --- a/src/index.test.js +++ /dev/null @@ -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"); - }); -}); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 93fe81e..0000000 --- a/src/index.ts +++ /dev/null @@ -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"; diff --git a/src/keys/index.ts b/src/keys/index.ts new file mode 100644 index 0000000..4de510b --- /dev/null +++ b/src/keys/index.ts @@ -0,0 +1 @@ +export * from "./keys"; diff --git a/src/keys.test.ts b/src/keys/keys.test.ts similarity index 54% rename from src/keys.test.ts rename to src/keys/keys.test.ts index 7aaafbf..f03026e 100644 --- a/src/keys.test.ts +++ b/src/keys/keys.test.ts @@ -1,37 +1,35 @@ import { describe, expect, test } from "vitest"; -import { HEX_64_PATTERN } from "./constants"; -import { Keys } from "./keys"; -import { testPK, testSK } from "./util.test"; +import { HEX_64_PATTERN } from "../constants"; +import { testPK, testSK } from "../util.test"; +import { generatePrivateKey, getPublicKey } from "./keys"; -describe("Keys.generatePrivate", () => { +describe("generatePrivate", () => { test("returns 64 hex characters", () => { - const privateKey = Keys.generatePrivateKey(); + const privateKey = generatePrivateKey(); expect(privateKey).toMatch(HEX_64_PATTERN); }); test("generates unique keys", () => { - const key1 = Keys.generatePrivateKey(); - const key2 = Keys.generatePrivateKey(); + const key1 = generatePrivateKey(); + const key2 = generatePrivateKey(); expect(key1).not.toBe(key2); }); }); -describe("Keys.getPublic", () => { +describe("getPublic", () => { test("derives correct public key", () => { - const publicKey = Keys.getPublicKey(testSK); + const publicKey = getPublicKey(testSK); expect(publicKey).toBe(testPK); }); test("throws on invalid private key - too short", () => { - expect(() => Keys.getPublicKey("abc123")).toThrow( - /"secret key" expected.*/, - ); + expect(() => getPublicKey("abc123")).toThrow(/"secret key" expected.*/); }); test("throws on invalid private key - non-hex", () => { expect(() => - Keys.getPublicKey( + getPublicKey( "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", ), ).toThrow(/hex string expected,.*/); diff --git a/src/keys.ts b/src/keys/keys.ts similarity index 82% rename from src/keys.ts rename to src/keys/keys.ts index e2622e6..7529ee7 100644 --- a/src/keys.ts +++ b/src/keys/keys.ts @@ -5,7 +5,7 @@ import { schnorr } from "@noble/secp256k1"; * Generates a new random secp256k1 private key. * @returns 64-character lowercase hexadecimal string */ -function generatePrivateKey(): string { +export function generatePrivateKey(): string { const { secretKey } = schnorr.keygen(); return bytesToHex(secretKey); } @@ -16,14 +16,9 @@ function generatePrivateKey(): string { * @returns 64-character lowercase hexadecimal public key (x-coordinate only) * @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 publicKeyBytes = schnorr.getPublicKey(privateKeyBytes); return bytesToHex(publicKeyBytes); } - -export const Keys = { - generatePrivateKey, - getPublicKey, -}; diff --git a/src/util.test.ts b/src/util.test.ts index c05524f..bd0719f 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -1,6 +1,6 @@ import { test } from "vitest"; -import type { EventData } from "./types"; +import type { Event } from "./events"; test("placeholder", () => {}); @@ -9,7 +9,7 @@ export const testSK = export const testPK = "cfa87f35acbde29ba1ab3ee42de527b2cad33ac487e80cf2d6405ea0042c8fef"; -export const testEvent: EventData = { +export const testEvent: Event = { id: "c7a702e6158744ca03508bbb4c90f9dbb0d6e88fefbfaa511d5ab24b4e3c48ad", pubkey: testPK, created_at: 1760740551,