feat: add ReadOnlyAccount class and login parser

Add support for read-only Nostr accounts that can be used for viewing
content without signing capability.

- ReadOnlyAccount class with factory methods for:
  - npub (NIP-19 encoded public key)
  - nprofile (NIP-19 encoded profile with relay hints)
  - hex (64-character hexadecimal public key)
  - nip05 (user@domain.com identifier)

- Login parser to detect input type and create accounts from various
  formats (npub, nprofile, hex, nip-05)

- Comprehensive test coverage (47 tests) for all account creation
  methods and input detection

All tests pass, lint clean, build successful.
This commit is contained in:
Claude
2026-01-04 19:01:47 +00:00
parent d21b351f5a
commit 507c86b123
4 changed files with 645 additions and 0 deletions

View File

@@ -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");
});
});
});

129
src/lib/account-types.ts Normal file
View File

@@ -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<string, any>;
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<ReadOnlyAccount["metadata"]>,
) {
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<ReadOnlyAccount> {
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<ReadOnlyAccount> {
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<ReadOnlyAccount> {
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,
});
}
}

View File

@@ -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);
});
});

100
src/lib/login-parser.ts Normal file
View File

@@ -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<ReadOnlyAccount> {
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";
}