4 Commits

Author SHA1 Message Date
Jay
79fd44a013 Bump version. Include license. 2025-11-02 14:49:43 -05:00
Jay
bbb4864088 Added license. 2025-11-02 14:32:41 -05:00
Jay
1d15331f82 Updated README. 2025-11-02 14:32:33 -05:00
Jay
efe2814c8a Refactor primitives into namespaces. 2025-10-31 20:44:36 -04:00
30 changed files with 215 additions and 276 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 nostr:npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -6,7 +6,7 @@ Mirror: https://github.com/wisehodl/ts-roots
## What this library does
`ts-roots` is a purposefully minimal Nostr protocol library for TypeScript.
`ts-roots` is a consensus-layer Nostr protocol library for TypeScript.
It only provides primitives that define protocol compliance:
- Event Structure
@@ -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",

View File

@@ -1,13 +1,28 @@
{
"name": "@wisehodl/roots",
"version": "0.1.0",
"version": "0.2.1",
"description": "A minimal Nostr protocol library for typescript",
"types": "./dist/types.d.ts",
"main": "./dist/index.js",
"license": "MIT",
"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": [

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 { 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);

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().
* @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,
};

View File

@@ -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);
});
});

View File

@@ -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,
};

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 { 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,.*/,
);
});

View File

@@ -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,
};

View File

@@ -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();
});
});

View File

@@ -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,
};

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.
* 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;

View File

@@ -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);

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().
* 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> = {};
// 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<string, any> = { ...json };
// Extract standard fields
@@ -96,8 +96,3 @@ function fromJSON(json: any): FilterData {
return filter;
}
export const FilterJSON = {
toJSON,
fromJSON,
};

View File

@@ -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);
});
});

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.
@@ -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,
};

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 { 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,.*/);

View File

@@ -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,
};

View File

@@ -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,