Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee0acf58f | |||
| 79fd44a013 | |||
| bbb4864088 | |||
| 1d15331f82 | |||
| efe2814c8a |
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
66
README.md
66
README.md
@@ -6,7 +6,7 @@ Mirror: https://github.com/wisehodl/ts-roots
|
|||||||
|
|
||||||
## What this library does
|
## 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:
|
It only provides primitives that define protocol compliance:
|
||||||
|
|
||||||
- Event Structure
|
- Event Structure
|
||||||
@@ -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",
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -1,13 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "@wisehodl/roots",
|
"name": "@wisehodl/roots",
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"description": "A minimal Nostr protocol library for typescript",
|
"description": "Nostr protocol primitives for typescript",
|
||||||
"types": "./dist/types.d.ts",
|
"license": "MIT",
|
||||||
"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": [
|
||||||
|
|||||||
11
src/event.ts
11
src/event.ts
@@ -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
20
src/events/event.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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
5
src/events/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./event";
|
||||||
|
export * from "./event_json";
|
||||||
|
export * from "./id";
|
||||||
|
export * from "./sign";
|
||||||
|
export * from "./validate";
|
||||||
@@ -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,.*/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { test } from "vitest";
|
|
||||||
|
|
||||||
test("placeholder", () => {});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { FilterJSON } from "./filter_json";
|
|
||||||
import { FilterMatch } from "./filter_match";
|
|
||||||
|
|
||||||
export const Filter = {
|
|
||||||
...FilterMatch,
|
|
||||||
...FilterJSON,
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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
3
src/filters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./filter";
|
||||||
|
export * from "./filter_json";
|
||||||
|
export * from "./filter_match";
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
1
src/keys/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./keys";
|
||||||
@@ -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,.*/);
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user