5 changed files with 269 additions and 30 deletions
@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
|
||||
import { VALIDATION } from './search_constants'; |
||||
|
||||
/** |
||||
* Nostr identifier types |
||||
*/ |
||||
export type NostrEventId = string; // 64-character hex string
|
||||
export type NostrCoordinate = string; // kind:pubkey:d-tag format
|
||||
export type NostrIdentifier = NostrEventId | NostrCoordinate; |
||||
|
||||
/** |
||||
* Interface for parsed Nostr coordinate |
||||
*/ |
||||
export interface ParsedCoordinate { |
||||
kind: number; |
||||
pubkey: string; |
||||
dTag: string; |
||||
} |
||||
|
||||
/** |
||||
* Check if a string is a valid hex event ID |
||||
* @param id The string to check |
||||
* @returns True if it's a valid hex event ID |
||||
*/ |
||||
export function isEventId(id: string): id is NostrEventId { |
||||
return new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(id); |
||||
} |
||||
|
||||
/** |
||||
* Check if a string is a valid Nostr coordinate (kind:pubkey:d-tag) |
||||
* @param coordinate The string to check |
||||
* @returns True if it's a valid coordinate |
||||
*/ |
||||
export function isCoordinate(coordinate: string): coordinate is NostrCoordinate { |
||||
const parts = coordinate.split(':'); |
||||
if (parts.length < 3) return false; |
||||
|
||||
const [kindStr, pubkey, ...dTagParts] = parts; |
||||
|
||||
// Check if kind is a valid number
|
||||
const kind = parseInt(kindStr, 10); |
||||
if (isNaN(kind) || kind < 0) return false; |
||||
|
||||
// Check if pubkey is a valid hex string
|
||||
if (!isEventId(pubkey)) return false; |
||||
|
||||
// Check if d-tag exists (can contain colons)
|
||||
if (dTagParts.length === 0) return false; |
||||
|
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* Parse a Nostr coordinate into its components |
||||
* @param coordinate The coordinate string to parse |
||||
* @returns Parsed coordinate or null if invalid |
||||
*/ |
||||
export function parseCoordinate(coordinate: string): ParsedCoordinate | null { |
||||
if (!isCoordinate(coordinate)) return null; |
||||
|
||||
const parts = coordinate.split(':'); |
||||
const [kindStr, pubkey, ...dTagParts] = parts; |
||||
|
||||
return { |
||||
kind: parseInt(kindStr, 10), |
||||
pubkey, |
||||
dTag: dTagParts.join(':') // Rejoin in case d-tag contains colons
|
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Create a coordinate string from components |
||||
* @param kind The event kind |
||||
* @param pubkey The author's public key |
||||
* @param dTag The d-tag value |
||||
* @returns The coordinate string |
||||
*/ |
||||
export function createCoordinate(kind: number, pubkey: string, dTag: string): NostrCoordinate { |
||||
return `${kind}:${pubkey}:${dTag}`; |
||||
} |
||||
|
||||
/** |
||||
* Check if a string is any valid Nostr identifier |
||||
* @param identifier The string to check |
||||
* @returns True if it's a valid Nostr identifier |
||||
*/ |
||||
export function isNostrIdentifier(identifier: string): identifier is NostrIdentifier { |
||||
return isEventId(identifier) || isCoordinate(identifier); |
||||
}
|
||||
@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest'; |
||||
import {
|
||||
isEventId,
|
||||
isCoordinate,
|
||||
parseCoordinate,
|
||||
createCoordinate, |
||||
isNostrIdentifier
|
||||
} from '../../src/lib/utils/nostr_identifiers'; |
||||
|
||||
describe('Nostr Identifier Validation', () => { |
||||
describe('isEventId', () => { |
||||
it('should validate correct hex event IDs', () => { |
||||
const validId = 'a'.repeat(64); |
||||
expect(isEventId(validId)).toBe(true); |
||||
|
||||
const validIdWithMixedCase = 'A'.repeat(32) + 'f'.repeat(32); |
||||
expect(isEventId(validIdWithMixedCase)).toBe(true); |
||||
}); |
||||
|
||||
it('should reject invalid event IDs', () => { |
||||
expect(isEventId('')).toBe(false); |
||||
expect(isEventId('abc')).toBe(false); |
||||
expect(isEventId('a'.repeat(63))).toBe(false); // too short
|
||||
expect(isEventId('a'.repeat(65))).toBe(false); // too long
|
||||
expect(isEventId('g'.repeat(64))).toBe(false); // invalid hex char
|
||||
}); |
||||
}); |
||||
|
||||
describe('isCoordinate', () => { |
||||
it('should validate correct coordinates', () => { |
||||
const validCoordinate = `30040:${'a'.repeat(64)}:chapter-1`; |
||||
expect(isCoordinate(validCoordinate)).toBe(true); |
||||
|
||||
const coordinateWithColonsInDTag = `30041:${'b'.repeat(64)}:chapter:with:colons`; |
||||
expect(isCoordinate(coordinateWithColonsInDTag)).toBe(true); |
||||
}); |
||||
|
||||
it('should reject invalid coordinates', () => { |
||||
expect(isCoordinate('')).toBe(false); |
||||
expect(isCoordinate('abc')).toBe(false); |
||||
expect(isCoordinate('30040:abc:chapter-1')).toBe(false); // invalid pubkey
|
||||
expect(isCoordinate('30040:abc')).toBe(false); // missing d-tag
|
||||
expect(isCoordinate('abc:def:ghi')).toBe(false); // invalid kind
|
||||
expect(isCoordinate('-1:abc:def')).toBe(false); // negative kind
|
||||
}); |
||||
}); |
||||
|
||||
describe('parseCoordinate', () => { |
||||
it('should parse valid coordinates correctly', () => { |
||||
const coordinate = `30040:${'a'.repeat(64)}:chapter-1`; |
||||
const parsed = parseCoordinate(coordinate); |
||||
|
||||
expect(parsed).toEqual({ |
||||
kind: 30040, |
||||
pubkey: 'a'.repeat(64), |
||||
dTag: 'chapter-1' |
||||
}); |
||||
}); |
||||
|
||||
it('should handle d-tags with colons', () => { |
||||
const coordinate = `30041:${'b'.repeat(64)}:chapter:with:colons`; |
||||
const parsed = parseCoordinate(coordinate); |
||||
|
||||
expect(parsed).toEqual({ |
||||
kind: 30041, |
||||
pubkey: 'b'.repeat(64), |
||||
dTag: 'chapter:with:colons' |
||||
}); |
||||
}); |
||||
|
||||
it('should return null for invalid coordinates', () => { |
||||
expect(parseCoordinate('')).toBeNull(); |
||||
expect(parseCoordinate('abc')).toBeNull(); |
||||
expect(parseCoordinate('30040:abc:chapter-1')).toBeNull(); |
||||
}); |
||||
}); |
||||
|
||||
describe('createCoordinate', () => { |
||||
it('should create valid coordinates', () => { |
||||
const coordinate = createCoordinate(30040, 'a'.repeat(64), 'chapter-1'); |
||||
expect(coordinate).toBe(`30040:${'a'.repeat(64)}:chapter-1`); |
||||
}); |
||||
|
||||
it('should handle d-tags with colons', () => { |
||||
const coordinate = createCoordinate(30041, 'b'.repeat(64), 'chapter:with:colons'); |
||||
expect(coordinate).toBe(`30041:${'b'.repeat(64)}:chapter:with:colons`); |
||||
}); |
||||
}); |
||||
|
||||
describe('isNostrIdentifier', () => { |
||||
it('should accept valid event IDs', () => { |
||||
expect(isNostrIdentifier('a'.repeat(64))).toBe(true); |
||||
}); |
||||
|
||||
it('should accept valid coordinates', () => { |
||||
const coordinate = `30040:${'a'.repeat(64)}:chapter-1`; |
||||
expect(isNostrIdentifier(coordinate)).toBe(true); |
||||
}); |
||||
|
||||
it('should reject invalid identifiers', () => { |
||||
expect(isNostrIdentifier('')).toBe(false); |
||||
expect(isNostrIdentifier('abc')).toBe(false); |
||||
expect(isNostrIdentifier('30040:abc:chapter-1')).toBe(false); |
||||
}); |
||||
}); |
||||
});
|
||||
Loading…
Reference in new issue