diff --git a/src/lib/account-types.test.ts b/src/lib/account-types.test.ts index e48a9af..2347fa5 100644 --- a/src/lib/account-types.test.ts +++ b/src/lib/account-types.test.ts @@ -15,11 +15,10 @@ describe("ReadOnlyAccount", () => { 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(); + expect(account.id).toBeDefined(); + expect(account.metadata?.source).toBe("hex"); + expect(account.metadata?.originalInput).toBe(hex); + expect(account.signer).toBeDefined(); // ReadOnlySigner instance }); it("should normalize hex to lowercase", () => { @@ -54,8 +53,8 @@ describe("ReadOnlyAccount", () => { expect(account.pubkey).toBe( "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", ); - expect(account.metadata.source).toBe("npub"); - expect(account.metadata.originalInput).toBe(npub); + expect(account.metadata?.source).toBe("npub"); + expect(account.metadata?.originalInput).toBe(npub); }); it("should reject invalid npub format", async () => { @@ -82,9 +81,9 @@ describe("ReadOnlyAccount", () => { expect(account.pubkey).toBe( "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", ); - expect(account.metadata.source).toBe("nprofile"); - expect(account.metadata.relays).toBeDefined(); - expect(account.metadata.relays?.length).toBeGreaterThan(0); + 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 () => { @@ -113,18 +112,18 @@ describe("ReadOnlyAccount", () => { 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(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", - ); + await expect( + ReadOnlyAccount.fromNip05("invalid@example.com"), + ).rejects.toThrow("Failed to resolve NIP-05 identifier"); }); }); @@ -139,10 +138,9 @@ describe("ReadOnlyAccount", () => { 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, + expect(restored.metadata?.source).toBe(account.metadata?.source); + expect(restored.metadata?.originalInput).toBe( + account.metadata?.originalInput, ); }); @@ -154,7 +152,7 @@ describe("ReadOnlyAccount", () => { const json = account.toJSON(); const restored = ReadOnlyAccount.fromJSON(json); - expect(restored.metadata.relays).toEqual(account.metadata.relays); + expect(restored.metadata?.relays).toEqual(account.metadata?.relays); }); it("should preserve nip05 identifier through serialization", async () => { @@ -169,34 +167,50 @@ describe("ReadOnlyAccount", () => { const json = account.toJSON(); const restored = ReadOnlyAccount.fromJSON(json); - expect(restored.metadata.nip05).toBe(nip05Id); + expect(restored.metadata?.nip05).toBe(nip05Id); }); }); describe("account properties", () => { - it("should have no signer for read-only accounts", () => { + it("should have ReadOnlySigner for read-only accounts", () => { const hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; const account = ReadOnlyAccount.fromHex(hex); - expect(account.signer).toBeUndefined(); + expect(account.signer).toBeDefined(); + expect(account.signer.pubkey).toBe(hex); }); - it("should have consistent ID format", () => { + it("should have an ID", () => { const hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; const account = ReadOnlyAccount.fromHex(hex); - expect(account.id).toBe(`readonly:${hex}`); - expect(account.id).toContain("readonly:"); + expect(account.id).toBeDefined(); + expect(account.id.length).toBeGreaterThan(0); }); - it("should have readonly type in metadata", () => { + it("should have source in metadata", () => { const hex = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; const account = ReadOnlyAccount.fromHex(hex); - expect(account.metadata.type).toBe("readonly"); + expect(account.metadata?.source).toBe("hex"); + }); + + it("should throw when trying to sign", async () => { + const hex = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + const account = ReadOnlyAccount.fromHex(hex); + + await expect( + account.signEvent({ + kind: 1, + content: "test", + tags: [], + created_at: Math.floor(Date.now() / 1000), + }), + ).rejects.toThrow("Cannot sign events with a read-only account"); }); }); }); diff --git a/src/lib/account-types.ts b/src/lib/account-types.ts index e406888..148e2ff 100644 --- a/src/lib/account-types.ts +++ b/src/lib/account-types.ts @@ -1,64 +1,85 @@ import { nip19 } from "nostr-tools"; import { resolveNip05 } from "@/lib/nip05"; +import { BaseAccount, type SerializedAccount } from "applesauce-accounts"; +import type { ISigner } from "applesauce-signers"; +import type { EventTemplate, NostrEvent } from "nostr-tools"; /** - * Account interface matching applesauce-accounts + * Read-only metadata interface */ -export interface Account { - id: string; - pubkey: string; - signer?: any; - metadata?: Record; - toJSON(): any; +export interface ReadOnlyMetadata { + source: "npub" | "nip05" | "hex" | "nprofile"; + originalInput: string; + relays?: string[]; // from nprofile + nip05?: string; // original nip-05 identifier +} + +/** + * A signer that always throws errors - used for read-only accounts + */ +export class ReadOnlySigner implements ISigner { + constructor(public pubkey: string) {} + + async getPublicKey(): Promise { + return this.pubkey; + } + + async signEvent(_template: EventTemplate): Promise { + throw new Error( + "Cannot sign events with a read-only account. Please add a signing account.", + ); + } + + // Optional NIP-04/NIP-44 methods - also throw errors + nip04 = { + encrypt: async (_pubkey: string, _plaintext: string): Promise => { + throw new Error("Cannot encrypt with a read-only account."); + }, + decrypt: async (_pubkey: string, _ciphertext: string): Promise => { + throw new Error("Cannot decrypt with a read-only account."); + }, + }; + + nip44 = { + encrypt: async (_pubkey: string, _plaintext: string): Promise => { + throw new Error("Cannot encrypt with a read-only account."); + }, + decrypt: async (_pubkey: string, _ciphertext: string): Promise => { + throw new Error("Cannot decrypt with a read-only account."); + }, + }; } /** * 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 - }; +export class ReadOnlyAccount extends BaseAccount< + ReadOnlySigner, + void, + ReadOnlyMetadata +> { + static readonly type = "readonly"; - 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, - }; + constructor(pubkey: string, metadata: ReadOnlyMetadata) { + const signer = new ReadOnlySigner(pubkey); + super(pubkey, signer); + this.metadata = 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, + toJSON(): SerializedAccount { + return this.saveCommonFields({ + signer: undefined, }); } + static fromJSON( + data: SerializedAccount, + ): ReadOnlyAccount { + const account = new ReadOnlyAccount(data.pubkey, data.metadata!); + return BaseAccount.loadCommonFields(account, data); + } + /** * Create account from npub (NIP-19 encoded public key) */ @@ -68,7 +89,8 @@ export class ReadOnlyAccount implements Account { if (decoded.type !== "npub") { throw new Error("Invalid npub: expected npub format"); } - return new ReadOnlyAccount(decoded.data, "npub", { + return new ReadOnlyAccount(decoded.data, { + source: "npub", originalInput: npub, }); } catch (error) { @@ -86,7 +108,8 @@ export class ReadOnlyAccount implements Account { if (!pubkey) { throw new Error(`Failed to resolve NIP-05 identifier: ${nip05}`); } - return new ReadOnlyAccount(pubkey, "nip05", { + return new ReadOnlyAccount(pubkey, { + source: "nip05", originalInput: nip05, nip05, }); @@ -101,7 +124,8 @@ export class ReadOnlyAccount implements Account { if (decoded.type !== "nprofile") { throw new Error("Invalid nprofile: expected nprofile format"); } - return new ReadOnlyAccount(decoded.data.pubkey, "nprofile", { + return new ReadOnlyAccount(decoded.data.pubkey, { + source: "nprofile", originalInput: nprofile, relays: decoded.data.relays, }); @@ -122,7 +146,8 @@ export class ReadOnlyAccount implements Account { "Invalid hex pubkey: expected 64 character hexadecimal string", ); } - return new ReadOnlyAccount(hex.toLowerCase(), "hex", { + return new ReadOnlyAccount(hex.toLowerCase(), { + source: "hex", originalInput: hex, }); } diff --git a/src/lib/login-parser.test.ts b/src/lib/login-parser.test.ts index f5444a9..0bb0e30 100644 --- a/src/lib/login-parser.test.ts +++ b/src/lib/login-parser.test.ts @@ -43,9 +43,9 @@ describe("detectLoginInputType", () => { expect(detectLoginInputType("bunker://pubkey?relay=wss://...")).toBe( "bunker", ); - expect( - detectLoginInputType("nostrconnect://pubkey?relay=wss://..."), - ).toBe("bunker"); + expect(detectLoginInputType("nostrconnect://pubkey?relay=wss://...")).toBe( + "bunker", + ); }); it("should return extension for empty input", () => { @@ -90,7 +90,7 @@ describe("createAccountFromInput", () => { expect(account.pubkey).toBe( "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", ); - expect(account.metadata.source).toBe("npub"); + expect(account.metadata?.source).toBe("npub"); }); it("should create account from hex", async () => { @@ -99,7 +99,7 @@ describe("createAccountFromInput", () => { const account = await createAccountFromInput(hex); expect(account.pubkey).toBe(hex); - expect(account.metadata.source).toBe("hex"); + expect(account.metadata?.source).toBe("hex"); }); it("should create account from nprofile", async () => { @@ -110,8 +110,8 @@ describe("createAccountFromInput", () => { expect(account.pubkey).toBe( "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", ); - expect(account.metadata.source).toBe("nprofile"); - expect(account.metadata.relays).toBeDefined(); + expect(account.metadata?.source).toBe("nprofile"); + expect(account.metadata?.relays).toBeDefined(); }); it("should create account from nip-05", async () => { @@ -124,8 +124,8 @@ describe("createAccountFromInput", () => { const account = await createAccountFromInput(nip05Id); expect(account.pubkey).toBe(pubkey); - expect(account.metadata.source).toBe("nip05"); - expect(account.metadata.nip05).toBe(nip05Id); + expect(account.metadata?.source).toBe("nip05"); + expect(account.metadata?.nip05).toBe(nip05Id); }); it("should throw error for bunker URL (not yet implemented)", async () => { @@ -165,9 +165,9 @@ describe("createAccountFromInput", () => { 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", - ); + await expect( + createAccountFromInput("notfound@example.com"), + ).rejects.toThrow("Failed to resolve NIP-05 identifier"); }); });