mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
refactor: integrate ReadOnlyAccount with applesauce-accounts
Update ReadOnlyAccount to extend BaseAccount from applesauce-accounts library for proper integration with AccountManager. Changes: - ReadOnlyAccount now extends BaseAccount<ReadOnlySigner, void, ReadOnlyMetadata> - Added ReadOnlySigner class that implements ISigner interface - ReadOnlySigner throws errors on all signing operations (sign, encrypt, decrypt) - Updated metadata structure to use ReadOnlyMetadata interface - Updated tests to match new implementation (metadata now optional) - Fixed TypeScript strict mode issues in tests All tests pass (48 tests), lint clean, build successful.
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, any>;
|
||||
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<string> {
|
||||
return this.pubkey;
|
||||
}
|
||||
|
||||
async signEvent(_template: EventTemplate): Promise<NostrEvent> {
|
||||
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<string> => {
|
||||
throw new Error("Cannot encrypt with a read-only account.");
|
||||
},
|
||||
decrypt: async (_pubkey: string, _ciphertext: string): Promise<string> => {
|
||||
throw new Error("Cannot decrypt with a read-only account.");
|
||||
},
|
||||
};
|
||||
|
||||
nip44 = {
|
||||
encrypt: async (_pubkey: string, _plaintext: string): Promise<string> => {
|
||||
throw new Error("Cannot encrypt with a read-only account.");
|
||||
},
|
||||
decrypt: async (_pubkey: string, _ciphertext: string): Promise<string> => {
|
||||
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<ReadOnlyAccount["metadata"]>,
|
||||
) {
|
||||
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<void, ReadOnlyMetadata> {
|
||||
return this.saveCommonFields({
|
||||
signer: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
static fromJSON(
|
||||
data: SerializedAccount<void, ReadOnlyMetadata>,
|
||||
): 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user