diff --git a/src/lib/account-types.test.ts b/src/lib/account-types.test.ts new file mode 100644 index 0000000..e48a9af --- /dev/null +++ b/src/lib/account-types.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi } from "vitest"; +import { ReadOnlyAccount } from "./account-types"; +import * as nip05 from "./nip05"; + +// Mock the NIP-05 resolver +vi.mock("./nip05", () => ({ + resolveNip05: vi.fn(), +})); + +describe("ReadOnlyAccount", () => { + describe("fromHex", () => { + it("should create account from valid hex pubkey", () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const account = ReadOnlyAccount.fromHex(hex); + + expect(account.pubkey).toBe(hex); + expect(account.id).toBe(`readonly:${hex}`); + expect(account.metadata.type).toBe("readonly"); + expect(account.metadata.source).toBe("hex"); + expect(account.metadata.originalInput).toBe(hex); + expect(account.signer).toBeUndefined(); + }); + + it("should normalize hex to lowercase", () => { + const hex = + "3BF0C63FCB93463407AF97A5E5EE64FA883D107EF9E558472C4EB9AAAEFA459D"; + const account = ReadOnlyAccount.fromHex(hex); + + expect(account.pubkey).toBe(hex.toLowerCase()); + }); + + it("should reject invalid hex length", () => { + expect(() => ReadOnlyAccount.fromHex("abc123")).toThrow( + "Invalid hex pubkey", + ); + }); + + it("should reject non-hex characters", () => { + const invalidHex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa45zz"; + expect(() => ReadOnlyAccount.fromHex(invalidHex)).toThrow( + "Invalid hex pubkey", + ); + }); + }); + + describe("fromNpub", () => { + it("should create account from valid npub", async () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const account = await ReadOnlyAccount.fromNpub(npub); + + expect(account.pubkey).toBe( + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ); + expect(account.metadata.source).toBe("npub"); + expect(account.metadata.originalInput).toBe(npub); + }); + + it("should reject invalid npub format", async () => { + await expect(ReadOnlyAccount.fromNpub("invalid")).rejects.toThrow( + "Failed to decode npub", + ); + }); + + it("should reject non-npub nip19 formats", async () => { + const nsec = + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; + await expect(ReadOnlyAccount.fromNpub(nsec)).rejects.toThrow( + "Invalid npub: expected npub format", + ); + }); + }); + + describe("fromNprofile", () => { + it("should create account from valid nprofile", async () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const account = await ReadOnlyAccount.fromNprofile(nprofile); + + expect(account.pubkey).toBe( + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ); + expect(account.metadata.source).toBe("nprofile"); + expect(account.metadata.relays).toBeDefined(); + expect(account.metadata.relays?.length).toBeGreaterThan(0); + }); + + it("should reject invalid nprofile format", async () => { + await expect(ReadOnlyAccount.fromNprofile("invalid")).rejects.toThrow( + "Failed to decode nprofile", + ); + }); + + it("should reject non-nprofile nip19 formats", async () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + await expect(ReadOnlyAccount.fromNprofile(npub)).rejects.toThrow( + "Invalid nprofile: expected nprofile format", + ); + }); + }); + + describe("fromNip05", () => { + it("should create account from valid nip-05", async () => { + const nip05Id = "alice@example.com"; + const pubkey = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + + vi.mocked(nip05.resolveNip05).mockResolvedValue(pubkey); + + const account = await ReadOnlyAccount.fromNip05(nip05Id); + + expect(account.pubkey).toBe(pubkey); + expect(account.metadata.source).toBe("nip05"); + expect(account.metadata.nip05).toBe(nip05Id); + expect(account.metadata.originalInput).toBe(nip05Id); + expect(nip05.resolveNip05).toHaveBeenCalledWith(nip05Id); + }); + + it("should reject when nip-05 resolution fails", async () => { + vi.mocked(nip05.resolveNip05).mockResolvedValue(null); + + await expect(ReadOnlyAccount.fromNip05("invalid@example.com")).rejects.toThrow( + "Failed to resolve NIP-05 identifier", + ); + }); + }); + + describe("toJSON and fromJSON", () => { + it("should serialize and deserialize correctly", () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const account = ReadOnlyAccount.fromHex(hex); + + const json = account.toJSON(); + const restored = ReadOnlyAccount.fromJSON(json); + + expect(restored.pubkey).toBe(account.pubkey); + expect(restored.id).toBe(account.id); + expect(restored.metadata.type).toBe(account.metadata.type); + expect(restored.metadata.source).toBe(account.metadata.source); + expect(restored.metadata.originalInput).toBe( + account.metadata.originalInput, + ); + }); + + it("should preserve nprofile relays through serialization", async () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const account = await ReadOnlyAccount.fromNprofile(nprofile); + + const json = account.toJSON(); + const restored = ReadOnlyAccount.fromJSON(json); + + expect(restored.metadata.relays).toEqual(account.metadata.relays); + }); + + it("should preserve nip05 identifier through serialization", async () => { + const nip05Id = "alice@example.com"; + const pubkey = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + + vi.mocked(nip05.resolveNip05).mockResolvedValue(pubkey); + + const account = await ReadOnlyAccount.fromNip05(nip05Id); + + const json = account.toJSON(); + const restored = ReadOnlyAccount.fromJSON(json); + + expect(restored.metadata.nip05).toBe(nip05Id); + }); + }); + + describe("account properties", () => { + it("should have no signer for read-only accounts", () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const account = ReadOnlyAccount.fromHex(hex); + + expect(account.signer).toBeUndefined(); + }); + + it("should have consistent ID format", () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const account = ReadOnlyAccount.fromHex(hex); + + expect(account.id).toBe(`readonly:${hex}`); + expect(account.id).toContain("readonly:"); + }); + + it("should have readonly type in metadata", () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const account = ReadOnlyAccount.fromHex(hex); + + expect(account.metadata.type).toBe("readonly"); + }); + }); +}); diff --git a/src/lib/account-types.ts b/src/lib/account-types.ts new file mode 100644 index 0000000..e406888 --- /dev/null +++ b/src/lib/account-types.ts @@ -0,0 +1,129 @@ +import { nip19 } from "nostr-tools"; +import { resolveNip05 } from "@/lib/nip05"; + +/** + * Account interface matching applesauce-accounts + */ +export interface Account { + id: string; + pubkey: string; + signer?: any; + metadata?: Record; + toJSON(): any; +} + +/** + * Read-only account - no signing capability + * Supports login via npub, nip-05, hex pubkey, or nprofile + */ +export class ReadOnlyAccount implements Account { + id: string; + pubkey: string; + signer = undefined; + metadata: { + type: "readonly"; + source: "npub" | "nip05" | "hex" | "nprofile"; + originalInput: string; + relays?: string[]; // from nprofile + nip05?: string; // original nip-05 identifier + }; + + constructor( + pubkey: string, + source: "npub" | "nip05" | "hex" | "nprofile", + metadata: Partial, + ) { + this.pubkey = pubkey; + this.id = `readonly:${pubkey}`; + this.metadata = { + type: "readonly", + source, + originalInput: metadata.originalInput || pubkey, + ...metadata, + }; + } + + toJSON() { + return { + id: this.id, + pubkey: this.pubkey, + metadata: this.metadata, + }; + } + + static fromJSON(data: any): ReadOnlyAccount { + return new ReadOnlyAccount(data.pubkey, data.metadata.source, { + originalInput: data.metadata.originalInput, + relays: data.metadata.relays, + nip05: data.metadata.nip05, + }); + } + + /** + * Create account from npub (NIP-19 encoded public key) + */ + static async fromNpub(npub: string): Promise { + try { + const decoded = nip19.decode(npub); + if (decoded.type !== "npub") { + throw new Error("Invalid npub: expected npub format"); + } + return new ReadOnlyAccount(decoded.data, "npub", { + originalInput: npub, + }); + } catch (error) { + throw new Error( + `Failed to decode npub: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Create account from NIP-05 identifier (user@domain.com) + */ + static async fromNip05(nip05: string): Promise { + const pubkey = await resolveNip05(nip05); + if (!pubkey) { + throw new Error(`Failed to resolve NIP-05 identifier: ${nip05}`); + } + return new ReadOnlyAccount(pubkey, "nip05", { + originalInput: nip05, + nip05, + }); + } + + /** + * Create account from nprofile (NIP-19 encoded profile with relay hints) + */ + static async fromNprofile(nprofile: string): Promise { + try { + const decoded = nip19.decode(nprofile); + if (decoded.type !== "nprofile") { + throw new Error("Invalid nprofile: expected nprofile format"); + } + return new ReadOnlyAccount(decoded.data.pubkey, "nprofile", { + originalInput: nprofile, + relays: decoded.data.relays, + }); + } catch (error) { + throw new Error( + `Failed to decode nprofile: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * Create account from hex public key + */ + static fromHex(hex: string): ReadOnlyAccount { + // Validate hex format (64 character hex string) + if (!/^[0-9a-f]{64}$/i.test(hex)) { + throw new Error( + "Invalid hex pubkey: expected 64 character hexadecimal string", + ); + } + return new ReadOnlyAccount(hex.toLowerCase(), "hex", { + originalInput: hex, + }); + } +} diff --git a/src/lib/login-parser.test.ts b/src/lib/login-parser.test.ts new file mode 100644 index 0000000..f5444a9 --- /dev/null +++ b/src/lib/login-parser.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi } from "vitest"; +import { + detectLoginInputType, + createAccountFromInput, + isValidLoginInput, +} from "./login-parser"; +import * as nip05 from "./nip05"; + +// Mock the NIP-05 module +vi.mock("./nip05", () => ({ + resolveNip05: vi.fn(), + isNip05: vi.fn((value: string) => { + // Simple mock implementation + return /^[a-zA-Z0-9._-]+@[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value); + }), +})); + +describe("detectLoginInputType", () => { + it("should detect npub format", () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + expect(detectLoginInputType(npub)).toBe("npub"); + }); + + it("should detect nprofile format", () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + expect(detectLoginInputType(nprofile)).toBe("nprofile"); + }); + + it("should detect hex pubkey format", () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + expect(detectLoginInputType(hex)).toBe("hex"); + }); + + it("should detect nip-05 format", () => { + expect(detectLoginInputType("alice@example.com")).toBe("nip05"); + expect(detectLoginInputType("bob@nostr.com")).toBe("nip05"); + }); + + it("should detect bunker URL format", () => { + expect(detectLoginInputType("bunker://pubkey?relay=wss://...")).toBe( + "bunker", + ); + expect( + detectLoginInputType("nostrconnect://pubkey?relay=wss://..."), + ).toBe("bunker"); + }); + + it("should return extension for empty input", () => { + expect(detectLoginInputType("")).toBe("extension"); + expect(detectLoginInputType(" ")).toBe("extension"); + }); + + it("should return unknown for invalid input", () => { + expect(detectLoginInputType("invalid")).toBe("unknown"); + expect(detectLoginInputType("random text")).toBe("unknown"); + }); + + it("should handle whitespace correctly", () => { + const npub = + " npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 "; + expect(detectLoginInputType(npub)).toBe("npub"); + }); + + it("should detect hex with uppercase", () => { + const hex = + "3BF0C63FCB93463407AF97A5E5EE64FA883D107EF9E558472C4EB9AAAEFA459D"; + expect(detectLoginInputType(hex)).toBe("hex"); + }); + + it("should reject too short hex", () => { + expect(detectLoginInputType("3bf0c63f")).toBe("unknown"); + }); + + it("should reject too long hex", () => { + const longHex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d00"; + expect(detectLoginInputType(longHex)).toBe("unknown"); + }); +}); + +describe("createAccountFromInput", () => { + it("should create account from npub", async () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const account = await createAccountFromInput(npub); + + expect(account.pubkey).toBe( + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ); + expect(account.metadata.source).toBe("npub"); + }); + + it("should create account from hex", async () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const account = await createAccountFromInput(hex); + + expect(account.pubkey).toBe(hex); + expect(account.metadata.source).toBe("hex"); + }); + + it("should create account from nprofile", async () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + const account = await createAccountFromInput(nprofile); + + expect(account.pubkey).toBe( + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + ); + expect(account.metadata.source).toBe("nprofile"); + expect(account.metadata.relays).toBeDefined(); + }); + + it("should create account from nip-05", async () => { + const nip05Id = "alice@example.com"; + const pubkey = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + + vi.mocked(nip05.resolveNip05).mockResolvedValue(pubkey); + + const account = await createAccountFromInput(nip05Id); + + expect(account.pubkey).toBe(pubkey); + expect(account.metadata.source).toBe("nip05"); + expect(account.metadata.nip05).toBe(nip05Id); + }); + + it("should throw error for bunker URL (not yet implemented)", async () => { + const bunker = "bunker://pubkey?relay=wss://relay.example.com"; + + await expect(createAccountFromInput(bunker)).rejects.toThrow( + "Remote signer (NIP-46) support coming soon", + ); + }); + + it("should throw error for extension (requires UI)", async () => { + await expect(createAccountFromInput("")).rejects.toThrow( + "Extension login requires UI interaction", + ); + }); + + it("should throw error for unknown format", async () => { + await expect(createAccountFromInput("invalid")).rejects.toThrow( + "Unknown input format", + ); + }); + + it("should handle whitespace in input", async () => { + const hex = + " 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d "; + const account = await createAccountFromInput(hex); + + expect(account.pubkey).toBe(hex.trim().toLowerCase()); + }); + + it("should throw descriptive error for invalid npub", async () => { + await expect(createAccountFromInput("npub1invalid")).rejects.toThrow( + "Failed to decode npub", + ); + }); + + it("should throw descriptive error for failed nip-05 resolution", async () => { + vi.mocked(nip05.resolveNip05).mockResolvedValue(null); + + await expect(createAccountFromInput("notfound@example.com")).rejects.toThrow( + "Failed to resolve NIP-05 identifier", + ); + }); +}); + +describe("isValidLoginInput", () => { + it("should return true for valid npub", () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + expect(isValidLoginInput(npub)).toBe(true); + }); + + it("should return true for valid hex", () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + expect(isValidLoginInput(hex)).toBe(true); + }); + + it("should return true for valid nip-05", () => { + expect(isValidLoginInput("alice@example.com")).toBe(true); + }); + + it("should return true for valid nprofile", () => { + const nprofile = + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; + expect(isValidLoginInput(nprofile)).toBe(true); + }); + + it("should return true for bunker URL", () => { + expect(isValidLoginInput("bunker://pubkey?relay=wss://...")).toBe(true); + }); + + it("should return false for empty input", () => { + expect(isValidLoginInput("")).toBe(false); + expect(isValidLoginInput(" ")).toBe(false); + }); + + it("should return false for unknown format", () => { + expect(isValidLoginInput("invalid")).toBe(false); + }); + + it("should return false for extension (requires UI)", () => { + // Empty string triggers extension type + expect(isValidLoginInput("")).toBe(false); + }); +}); diff --git a/src/lib/login-parser.ts b/src/lib/login-parser.ts new file mode 100644 index 0000000..06ebebf --- /dev/null +++ b/src/lib/login-parser.ts @@ -0,0 +1,100 @@ +import { isNip05 } from "@/lib/nip05"; +import { ReadOnlyAccount } from "@/lib/account-types"; + +/** + * Types of login input formats supported + */ +export type LoginInputType = + | "npub" + | "nprofile" + | "nip05" + | "hex" + | "bunker" + | "extension" + | "unknown"; + +/** + * Detect the type of login input + * @param input - The user's login input string + * @returns The detected input type + */ +export function detectLoginInputType(input: string): LoginInputType { + if (!input || input.trim() === "") { + return "extension"; // Default to extension when no input + } + + const trimmed = input.trim(); + + // NIP-19 encoded formats + if (trimmed.startsWith("npub1")) return "npub"; + if (trimmed.startsWith("nprofile1")) return "nprofile"; + + // Bunker/Nostr Connect URLs (NIP-46) + if (trimmed.startsWith("bunker://")) return "bunker"; + if (trimmed.startsWith("nostrconnect://")) return "bunker"; + + // NIP-05 identifier (user@domain.com or domain.com) + if (isNip05(trimmed)) return "nip05"; + + // Hex pubkey (64 character hex string) + if (/^[0-9a-f]{64}$/i.test(trimmed)) return "hex"; + + return "unknown"; +} + +/** + * Create an account from login input + * @param input - The user's login input string + * @returns A promise that resolves to an Account + * @throws Error if the input format is invalid or account creation fails + */ +export async function createAccountFromInput( + input: string, +): Promise { + const trimmed = input.trim(); + const type = detectLoginInputType(trimmed); + + switch (type) { + case "npub": + return await ReadOnlyAccount.fromNpub(trimmed); + + case "nprofile": + return await ReadOnlyAccount.fromNprofile(trimmed); + + case "nip05": + return await ReadOnlyAccount.fromNip05(trimmed); + + case "hex": + return ReadOnlyAccount.fromHex(trimmed); + + case "bunker": + throw new Error( + "Remote signer (NIP-46) support coming soon. Currently supports read-only accounts only.", + ); + + case "extension": + throw new Error( + "Extension login requires UI interaction. Please use the login dialog.", + ); + + case "unknown": + default: + throw new Error( + `Unknown input format. Supported formats: npub, nprofile, hex pubkey, NIP-05 (user@domain.com)`, + ); + } +} + +/** + * Validate if an input string is a valid login format + * @param input - The input to validate + * @returns True if the input is a valid format + */ +export function isValidLoginInput(input: string): boolean { + if (!input || input.trim() === "") { + return false; // Empty input is invalid for this check + } + + const type = detectLoginInputType(input); + return type !== "unknown" && type !== "extension"; +}