mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
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:
202
src/lib/account-types.test.ts
Normal file
202
src/lib/account-types.test.ts
Normal 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
129
src/lib/account-types.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
214
src/lib/login-parser.test.ts
Normal file
214
src/lib/login-parser.test.ts
Normal 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
100
src/lib/login-parser.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user