5 changed files with 269 additions and 30 deletions
@ -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 @@ |
|||||||
|
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