diff --git a/apps/web/components/apps/AppSetupPage.tsx b/apps/web/components/apps/AppSetupPage.tsx index a31f52fc3ffc11..51638da08f7ccf 100644 --- a/apps/web/components/apps/AppSetupPage.tsx +++ b/apps/web/components/apps/AppSetupPage.tsx @@ -17,6 +17,7 @@ export const AppSetupMap = { paypal: dynamic(() => import("@calcom/web/components/apps/paypal/Setup")), hitpay: dynamic(() => import("@calcom/web/components/apps/hitpay/Setup")), btcpayserver: dynamic(() => import("@calcom/web/components/apps/btcpayserver/Setup")), + nostrcalendar: dynamic(() => import("@calcom/web/components/apps/nostrcalendar/Setup")), }; export const AppSetupPage = (props: { slug: string }) => { diff --git a/apps/web/components/apps/nostrcalendar/Setup.tsx b/apps/web/components/apps/nostrcalendar/Setup.tsx new file mode 100644 index 00000000000000..fded542f8e148b --- /dev/null +++ b/apps/web/components/apps/nostrcalendar/Setup.tsx @@ -0,0 +1,179 @@ +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Toaster } from "sonner"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Alert } from "@calcom/ui/components/alert"; +import { Button } from "@calcom/ui/components/button"; +import { Form, TextField } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; + +interface FormData { + authMethod: "bunker" | "nsec"; + bunkerUri?: string; + nsec?: string; +} + +export default function NostrSetup() { + const { t } = useLocale(); + const router = useRouter(); + const form = useForm({ + defaultValues: { + authMethod: "bunker", // Default to bunker as recommended + bunkerUri: "", + nsec: "", + }, + }); + + const [errorMessage, setErrorMessage] = useState(""); + const authMethod = form.watch("authMethod"); + + return ( +
+
+
+
+ Nostr +
+
+

Connect to Nostr

+
+ Choose how you want to authenticate with Nostr for managing your calendar events. +
+ +
+
{ + setErrorMessage(""); + + const res = await fetch("/api/integrations/nostrcalendar/add", { + method: "POST", + body: JSON.stringify({ + authMethod: values.authMethod, + bunkerUri: values.bunkerUri, + nsec: values.nsec, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || t("something_went_wrong")); + } else { + router.push(json.url); + } + }}> +
+ {/* Authentication Method Selection */} +
+ + + + + +
+ + {/* Conditional Input Fields */} + {authMethod === "bunker" ? ( + <> + + +
+ Your keys stay secure in + the bunker. You'll need to approve the connection in your bunker app. +
+ +
+ + What permissions will be requested? + +
    +
  • • Sign calendar events (kinds 31922, 31923, 31927)
  • +
  • • Sign seals for private events (kind 13)
  • +
  • • Sign deletion events (kind 5)
  • +
  • • Encrypt/decrypt content (NIP-44)
  • +
+
+ + ) : ( + <> + + +
+ Your nsec key will be + encrypted before storage. Never share this key with anyone. +
+ + )} + +
+ Your relay list will be + automatically discovered from your kind 10002 relay list metadata. +
+
+ + {errorMessage && } + +
+ + +
+ +
+
+
+
+ +
+ ); +} diff --git a/packages/app-store/_pages/setup/_getServerSideProps.tsx b/packages/app-store/_pages/setup/_getServerSideProps.tsx index d21415b793c0e1..ccc857174cbdbc 100644 --- a/packages/app-store/_pages/setup/_getServerSideProps.tsx +++ b/packages/app-store/_pages/setup/_getServerSideProps.tsx @@ -7,6 +7,7 @@ export const AppSetupPageMap = { stripe: import("../../stripepayment/pages/setup/_getServerSideProps"), hitpay: import("../../hitpay/pages/setup/_getServerSideProps"), btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"), + nostrcalendar: import("../../nostrcalendar/pages/setup/_getServerSideProps"), }; export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 9cf061a4401670..afcf257c01c301 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -27,6 +27,7 @@ import { appKeysSchema as matomo_zod_ts } from "./matomo/zod"; import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appKeysSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod"; import { appKeysSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; +import { appKeysSchema as nostrcalendar_zod_ts } from "./nostrcalendar/zod"; import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appKeysSchema as office365video_zod_ts } from "./office365video/zod"; import { appKeysSchema as paypal_zod_ts } from "./paypal/zod"; @@ -78,6 +79,7 @@ export const appKeysSchemas = { metapixel: metapixel_zod_ts, "mock-payment-app": mock_payment_app_zod_ts, nextcloudtalk: nextcloudtalk_zod_ts, + nostrcalendar: nostrcalendar_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 1749ef3c95aa2a..3fbd87fa812137 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -60,6 +60,7 @@ import mock_payment_app_config_json from "./mock-payment-app/config.json"; import monobot_config_json from "./monobot/config.json"; import n8n_config_json from "./n8n/config.json"; import nextcloudtalk_config_json from "./nextcloudtalk/config.json"; +import nostrcalendar_config_json from "./nostrcalendar/config.json"; import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata"; import office365video_config_json from "./office365video/config.json"; import paypal_config_json from "./paypal/config.json"; @@ -168,6 +169,7 @@ export const appStoreMetadata = { monobot: monobot_config_json, n8n: n8n_config_json, nextcloudtalk: nextcloudtalk_config_json, + nostrcalendar: nostrcalendar_config_json, office365calendar: office365calendar__metadata_ts, office365video: office365video_config_json, paypal: paypal_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index f1cad4389f7374..ef8690984d4d2b 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -27,6 +27,7 @@ import { appDataSchema as matomo_zod_ts } from "./matomo/zod"; import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appDataSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod"; import { appDataSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; +import { appDataSchema as nostrcalendar_zod_ts } from "./nostrcalendar/zod"; import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appDataSchema as office365video_zod_ts } from "./office365video/zod"; import { appDataSchema as paypal_zod_ts } from "./paypal/zod"; @@ -78,6 +79,7 @@ export const appDataSchemas = { metapixel: metapixel_zod_ts, "mock-payment-app": mock_payment_app_zod_ts, nextcloudtalk: nextcloudtalk_zod_ts, + nostrcalendar: nostrcalendar_zod_ts, office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index de8307de54f744..1e2b2c134f61ab 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -60,6 +60,7 @@ export const apiHandlers = { monobot: import("./monobot/api"), n8n: import("./n8n/api"), nextcloudtalk: import("./nextcloudtalk/api"), + nostrcalendar: import("./nostrcalendar/api"), office365calendar: import("./office365calendar/api"), office365video: import("./office365video/api"), paypal: import("./paypal/api"), diff --git a/packages/app-store/calendar.services.generated.ts b/packages/app-store/calendar.services.generated.ts index 9795cfe01ea444..a57661168c05bb 100644 --- a/packages/app-store/calendar.services.generated.ts +++ b/packages/app-store/calendar.services.generated.ts @@ -15,6 +15,7 @@ export const CalendarServiceMap = googlecalendar: import("./googlecalendar/lib/CalendarService"), "ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"), larkcalendar: import("./larkcalendar/lib/CalendarService"), + nostrcalendar: import("./nostrcalendar/lib/CalendarService"), office365calendar: import("./office365calendar/lib/CalendarService"), zohocalendar: import("./zohocalendar/lib/CalendarService"), }; diff --git a/packages/app-store/nostrcalendar/DESCRIPTION.md b/packages/app-store/nostrcalendar/DESCRIPTION.md new file mode 100644 index 00000000000000..b002d3656bed24 --- /dev/null +++ b/packages/app-store/nostrcalendar/DESCRIPTION.md @@ -0,0 +1,52 @@ +--- +items: + - 1.jpeg + - 2.jpeg + - 3.jpeg +--- + +# Nostr Calendar + +Sync your Cal.com bookings with Nostr using the NIP-52 calendar events specification. + +## What it does + +When someone books a meeting with you on Cal.com, this app: +- Publishes a private NIP-59 gift-wrapped calendar event (kind 31923) to your Nostr relays +- Creates a public availability block (kind 31927) to mark you as busy +- Checks your existing Nostr calendar events to prevent double-bookings + +## Authentication + +Choose how you want to connect: + +**Bunker (recommended)** - Connect a remote signer like nsec.app or Amber. Your keys stay in the signer and never touch Cal.com's servers. + +**Private Key (nsec)** - Provide your nsec key directly. It's encrypted before storage using the same encryption Cal.com uses for other integrations. + +## Setup + +1. Install the app from the Cal.com app store +2. Choose bunker or nsec authentication +3. Enable "Check for conflicts" in your calendar settings to sync availability +4. Done - your relay list is automatically discovered from your kind 10002 metadata + +## Privacy + +By default, calendar events are created as private NIP-59 gift-wrapped events. Event details are encrypted and only visible to participants. A public availability block (kind 31927) is created so others can see you're busy without seeing the event details. + +## Implementation + +Implements the following Nostr specs: +- NIP-52 (Calendar Events) +- NIP-46 (Nostr Connect / bunker) +- NIP-59 (Gift Wrap for private events) +- NIP-44 (Encrypted payloads) +- NIP-09 (Event deletion) + +Supports all NIP-52 event types: date-based (31922), time-based (31923), RSVPs (31925), and availability blocks (31927). + +## Learn More + +- [NIP-52 Specification](https://github.com/nostr-protocol/nips/blob/master/52.md) +- [Nostr Protocol](https://nostr.com) diff --git a/packages/app-store/nostrcalendar/_metadata.ts b/packages/app-store/nostrcalendar/_metadata.ts new file mode 100644 index 00000000000000..bf65744a8af13e --- /dev/null +++ b/packages/app-store/nostrcalendar/_metadata.ts @@ -0,0 +1,23 @@ +import type { AppMeta } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Nostr", + description: _package.description, + installed: true, + type: "nostr_calendar", + title: "Nostr Calendar", + variant: "calendar", + category: "calendar", + categories: ["calendar"], + logo: "icon.svg", + publisher: "NostrCal.com", + slug: "nostr", + url: "https://nostr.com", + email: "hello@nostrcal.com", + dirName: "nostrcalendar", + isOAuth: false, +} as AppMeta; + +export default metadata; diff --git a/packages/app-store/nostrcalendar/api/add.ts b/packages/app-store/nostrcalendar/api/add.ts new file mode 100644 index 00000000000000..d2d38250576604 --- /dev/null +++ b/packages/app-store/nostrcalendar/api/add.ts @@ -0,0 +1,164 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { nip19, getPublicKey } from "nostr-tools"; + +import { symmetricEncrypt } from "@calcom/lib/crypto"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { defaultHandler } from "@calcom/lib/server/defaultHandler"; +import { defaultResponder } from "@calcom/lib/server/defaultResponder"; +import prisma from "@calcom/prisma"; + +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import appConfig from "../config.json"; +import { BunkerManager } from "../lib/BunkerManager"; +import { NostrClient } from "../lib/NostrClient"; + +const log = logger.getSubLogger({ prefix: ["nostr/api/add"] }); + +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + // GET: Return setup URL + return res.status(200).json({ url: "/apps/nostrcalendar/setup" }); +} + +async function postHandler(req: NextApiRequest, res: NextApiResponse) { + const loggedInUser = req.session?.user; + + if (!loggedInUser) { + throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" }); + } + + const { authMethod, bunkerUri, nsec } = req.body; + + // Validate authMethod + if (!authMethod || (authMethod !== "bunker" && authMethod !== "nsec")) { + throw new HttpError({ statusCode: 400, message: "authMethod must be 'bunker' or 'nsec'" }); + } + + const encryptionKey = process.env.CALENDSO_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error("CALENDSO_ENCRYPTION_KEY is not configured"); + } + + const decodedKey = Buffer.from(encryptionKey, "base64").toString("latin1"); + + try { + if (authMethod === "bunker") { + // Bunker-based authentication + if (!bunkerUri || typeof bunkerUri !== "string") { + throw new HttpError({ statusCode: 400, message: "bunkerUri is required for bunker auth" }); + } + + log.info("Setting up bunker authentication", { userId: loggedInUser.id }); + + // Validate bunker URI format + if (!BunkerManager.isValidBunkerUri(bunkerUri)) { + throw new Error("Invalid bunker URI format. Expected bunker:// or name@domain.com"); + } + + // Connect to bunker + const { bunker, clientSecret } = await BunkerManager.connectFromUri(bunkerUri); + + // Get public key from bunker + const publicKey = await bunker.getPublicKey(); + const npub = nip19.npubEncode(publicKey); + + log.info("Connected to bunker successfully", { + userId: loggedInUser.id, + npub: npub.substring(0, 12) + "...", + }); + + // Encrypt client secret for storage + const encryptedClientSecret = symmetricEncrypt(Buffer.from(clientSecret).toString("hex"), decodedKey); + + // Query user's kind 0 metadata to get display name + const nostrClient = new NostrClient({ bunker }); + const displayName = await nostrClient.queryUserMetadata(); + + // Store credential + const data = { + type: appConfig.type, + key: { + authType: "bunker" as const, + bunkerUri, + localClientSecret: encryptedClientSecret, + npub, + ...(displayName && { displayName }), + }, + userId: loggedInUser.id, + teamId: null, + appId: appConfig.slug, + invalid: false, + }; + + await prisma.credential.create({ data }); + + // Clean up bunker connection + await bunker.close(); + + log.info("Bunker credential created successfully", { userId: loggedInUser.id, npub }); + + return res.status(200).json({ + url: getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }), + }); + } else { + // nsec-based authentication (existing flow) + if (!nsec || typeof nsec !== "string") { + throw new HttpError({ statusCode: 400, message: "nsec key is required for nsec auth" }); + } + + log.info("Setting up nsec authentication", { userId: loggedInUser.id }); + + // Decode nsec to validate and get public key + const decoded = nip19.decode(nsec); + if (decoded.type !== "nsec") { + throw new Error("Invalid nsec key format"); + } + + const secretKey = decoded.data as Uint8Array; + const publicKey = getPublicKey(secretKey); + const npub = nip19.npubEncode(publicKey); + + // Encrypt the nsec before storing + const encryptedNsec = symmetricEncrypt(nsec, decodedKey); + + // Query user's kind 0 metadata to get display name + const nostrClient = new NostrClient({ nsec }); + const displayName = await nostrClient.queryUserMetadata(publicKey); + nostrClient.close(); + + // Store credential (relays will be discovered from kind 10002) + const data = { + type: appConfig.type, + key: { + authType: "nsec" as const, + nsec: encryptedNsec, + npub, + ...(displayName && { displayName }), + }, + userId: loggedInUser.id, + teamId: null, + appId: appConfig.slug, + invalid: false, + }; + + await prisma.credential.create({ data }); + + log.info("Nsec credential created successfully", { userId: loggedInUser.id, npub }); + + return res.status(200).json({ + url: getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }), + }); + } + } catch (e) { + const error = e as Error; + log.error("Could not add Nostr account", error); + return res.status(500).json({ + message: error.message || "Could not add Nostr account. Please verify your credentials are correct.", + }); + } +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), + POST: Promise.resolve({ default: defaultResponder(postHandler) }), +}); diff --git a/packages/app-store/nostrcalendar/api/index.ts b/packages/app-store/nostrcalendar/api/index.ts new file mode 100644 index 00000000000000..4c0d2ead01e1f9 --- /dev/null +++ b/packages/app-store/nostrcalendar/api/index.ts @@ -0,0 +1 @@ +export { default as add } from "./add"; diff --git a/packages/app-store/nostrcalendar/components/.gitkeep b/packages/app-store/nostrcalendar/components/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/app-store/nostrcalendar/config.json b/packages/app-store/nostrcalendar/config.json new file mode 100644 index 00000000000000..7f76590f45d40d --- /dev/null +++ b/packages/app-store/nostrcalendar/config.json @@ -0,0 +1,17 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "nostrcalendar", + "slug": "nostrcalendar", + "type": "nostr_calendar", + "title": "Nostr Calendar", + "logo": "icon.svg", + "url": "https://nostrcal.com", + "variant": "calendar", + "categories": ["calendar"], + "publisher": "NostrCal.com", + "email": "hello@nostrcal.com", + "description": "Sync your calendar with Nostr (NIP-52) events", + "isTemplate": false, + "__createdUsingCli": true, + "__template": "basic" +} diff --git a/packages/app-store/nostrcalendar/index.ts b/packages/app-store/nostrcalendar/index.ts new file mode 100644 index 00000000000000..e2e9d7b029c031 --- /dev/null +++ b/packages/app-store/nostrcalendar/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/nostrcalendar/lib/BunkerManager.ts b/packages/app-store/nostrcalendar/lib/BunkerManager.ts new file mode 100644 index 00000000000000..11d1a1ec25d95c --- /dev/null +++ b/packages/app-store/nostrcalendar/lib/BunkerManager.ts @@ -0,0 +1,165 @@ +// TypeScript types imported from lib/types path for moduleResolution: "node" compatibility +import { SimplePool, generateSecretKey } from "nostr-tools"; +import type { BunkerSigner } from "nostr-tools/lib/types/nip46"; +// @ts-expect-error - TypeScript can't resolve this with moduleResolution: "node", but it works at runtime via package.json exports +import { BunkerSigner as BunkerSignerClass, parseBunkerInput, BUNKER_REGEX } from "nostr-tools/nip46"; + +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import logger from "@calcom/lib/logger"; + +import type { BunkerConnection } from "./types"; + +const log = logger.getSubLogger({ prefix: ["bunker-manager"] }); + +// Permissions required for calendar operations +const CALENDAR_PERMISSIONS = [ + "get_public_key", // Get the user's public key + "sign_event:31922", // Sign date-based calendar events + "sign_event:31923", // Sign time-based calendar events + "sign_event:31927", // Sign availability blocks + "sign_event:5", // Sign deletion events + "sign_event:13", // Sign seals (for private events via NIP-59) + "nip44_encrypt", // Encrypt content for private events + "nip44_decrypt", // Decrypt received private events +]; + +export class BunkerManager { + /** + * Connect to a bunker using a bunker URI + * @param bunkerUri - bunker:// URI or NIP-05 identifier + * @param localClientSecret - Optional client secret for reconnection (if not provided, generates new one) + * @returns BunkerSigner instance and client secret for storage + */ + static async connectFromUri(bunkerUri: string, localClientSecret?: Uint8Array): Promise { + log.info("Connecting to bunker", { uri: bunkerUri.substring(0, 20) + "..." }); + + // Generate or reuse client secret + const clientSecret = localClientSecret || generateSecretKey(); + + // Parse the bunker URI + const bunkerPointer = await parseBunkerInput(bunkerUri); + if (!bunkerPointer) { + throw new Error("Invalid bunker URI format. Expected bunker:// or name@domain.com"); + } + + log.debug("Bunker pointer parsed", { + relays: bunkerPointer.relays, + pubkey: bunkerPointer.pubkey.slice(0, 8), + }); + + // Create pool and bunker signer + const pool = new SimplePool(); + const bunker = BunkerSignerClass.fromBunker(clientSecret, bunkerPointer, { pool }); + + // Connect and request permissions + // Note: This will prompt the user in their bunker app to approve permissions + try { + // Format permissions as comma-separated string per NIP-46 spec + const permissions = CALENDAR_PERMISSIONS.join(","); + log.debug("Requesting permissions", { permissions }); + + // Send connect request with permissions + // NIP-46 format: ["connect", , , ] + await bunker.sendRequest("connect", [bunkerPointer.pubkey, bunkerPointer.secret || "", permissions]); + log.info("Successfully connected to bunker with calendar permissions"); + } catch (error) { + log.error("Failed to connect to bunker", error); + pool.close([]); + throw new Error( + `Bunker connection failed: ${error instanceof Error ? error.message : "Unknown error"}. ` + + "Please approve the connection in your bunker app." + ); + } + + return { bunker, clientSecret }; + } + + /** + * Reconnect to a bunker using stored encrypted client secret + * Used when loading existing credentials + * @param bunkerUri - The bunker URI + * @param encryptedClientSecret - Encrypted client secret from database + * @param encryptionKey - Decryption key + * @returns BunkerSigner instance + */ + static async reconnect( + bunkerUri: string, + encryptedClientSecret: string, + encryptionKey: string + ): Promise { + log.info("Reconnecting to bunker with stored secret"); + + // Decrypt the client secret + const clientSecretHex = symmetricDecrypt(encryptedClientSecret, encryptionKey); + const clientSecret = Buffer.from(clientSecretHex, "hex"); + + log.debug("Client secret decrypted", { length: clientSecret.length }); + + // Parse the bunker URI + const bunkerPointer = await parseBunkerInput(bunkerUri); + if (!bunkerPointer) { + throw new Error("Invalid bunker URI format"); + } + + // Create pool and bunker signer with stored secret + const pool = new SimplePool(); + const bunker = BunkerSignerClass.fromBunker(clientSecret, bunkerPointer, { pool }); + + // fromBunker() automatically sets up subscription and makes the signer ready to use + // Do NOT call connect() on reconnection - the bunker will ignore it since the secret + // was already used for the initial connection (per NIP-46 spec) + // The signer is operational immediately and will handle requests + log.info("BunkerSigner reconnected (ready for operations)"); + + return bunker; + } + + /** + * Validate a bunker URI format + * @param uri - The URI to validate + * @returns True if valid bunker URI + */ + static isValidBunkerUri(uri: string): boolean { + // Check if it matches bunker:// pattern or NIP-05 format + if (BUNKER_REGEX.test(uri)) { + return true; + } + + // Also accept NIP-05 format: name@domain.com + const nip05Regex = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return nip05Regex.test(uri); + } + + /** + * Test a bunker connection without storing credentials + * Useful for validation during setup + * @param bunkerUri - The bunker URI to test + * @returns True if connection successful + */ + static async testConnection(bunkerUri: string): Promise { + try { + log.info("Testing bunker connection"); + const { bunker } = await this.connectFromUri(bunkerUri); + + // Try to get public key as a connection test + await bunker.getPublicKey(); + + // Clean up + await bunker.close(); + + log.info("Bunker connection test successful"); + return true; + } catch (error) { + log.warn("Bunker connection test failed", error); + return false; + } + } + + /** + * Get list of permissions required for calendar operations + * Used for display in UI + */ + static getRequiredPermissions(): string[] { + return [...CALENDAR_PERMISSIONS]; + } +} diff --git a/packages/app-store/nostrcalendar/lib/CalendarService.ts b/packages/app-store/nostrcalendar/lib/CalendarService.ts new file mode 100644 index 00000000000000..4c825fe594614e --- /dev/null +++ b/packages/app-store/nostrcalendar/lib/CalendarService.ts @@ -0,0 +1,296 @@ +import { getLocation } from "@calcom/lib/CalEventParser"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import logger from "@calcom/lib/logger"; +import type { + Calendar, + CalendarEvent, + CalendarServiceEvent, + EventBusyDate, + IntegrationCalendar, + NewCalendarEventType, +} from "@calcom/types/Calendar"; +import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; + +import type { NostrCredential } from "../zod"; +import { BunkerManager } from "./BunkerManager"; +import { NostrClient } from "./NostrClient"; + +const log = logger.getSubLogger({ prefix: ["nostr-calendar-service"] }); + +export default class NostrCalendarService implements Calendar { + private integrationName = "nostr_calendar"; + private credential: CredentialForCalendarServiceWithEmail; + private nostrClient: NostrClient | null = null; + + constructor(credential: CredentialForCalendarServiceWithEmail) { + this.credential = credential; + } + + /** + * Initialize Nostr client (lazy initialization) + * Supports both nsec and bunker authentication + */ + private async getNostrClient(): Promise { + if (this.nostrClient) { + return this.nostrClient; + } + + const credentialKey = this.credential.key as NostrCredential; + + // Get encryption key for decrypting stored secrets + const encryptionKey = process.env.CALENDSO_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error("CALENDSO_ENCRYPTION_KEY is not configured"); + } + + // The encryption key is stored as base64, but symmetricDecrypt expects latin1 + const decodedKey = Buffer.from(encryptionKey, "base64").toString("latin1"); + + if (credentialKey.authType === "bunker") { + // Bunker authentication + log.info("Initializing NostrClient with bunker auth"); + + try { + const bunker = await BunkerManager.reconnect( + credentialKey.bunkerUri, + credentialKey.localClientSecret, + decodedKey + ); + + this.nostrClient = new NostrClient({ bunker }); + log.info("NostrClient initialized with bunker successfully"); + } catch (error) { + log.error("Failed to reconnect to bunker", error); + throw new Error( + "Failed to connect to bunker. Please reconnect the Nostr Calendar app in your settings." + ); + } + } else { + // nsec authentication + log.info("Initializing NostrClient with nsec auth"); + + const nsec = symmetricDecrypt(credentialKey.nsec, decodedKey); + this.nostrClient = new NostrClient({ nsec }); + log.info("NostrClient initialized with nsec successfully"); + } + + return this.nostrClient; + } + + /** + * Get credential ID + */ + public getCredentialId(): number { + return this.credential.id; + } + + /** + * Create a new calendar event in Nostr + */ + async createEvent(calEvent: CalendarServiceEvent, _credentialId: number): Promise { + log.info("Creating Nostr calendar event", { + title: calEvent.title, + startTime: new Date(calEvent.startTime).toISOString(), + endTime: new Date(calEvent.endTime).toISOString(), + }); + + try { + const client = await this.getNostrClient(); + + // Use Cal.com's standard location parser which handles video URLs correctly + const location = getLocation(calEvent) || undefined; + + // Create the main calendar event (kind 31923) + const eventId = await client.createCalendarEvent({ + title: calEvent.title, + description: calEvent.additionalNotes || "", + startTime: new Date(calEvent.startTime), + endTime: new Date(calEvent.endTime), + timezone: calEvent.organizer.timeZone, + location: location, + // Optionally include attendee npubs if available + }); + + // Also create an availability block (kind 31927) to mark as busy + const blockId = await client.createAvailabilityBlock({ + startTime: new Date(calEvent.startTime), + endTime: new Date(calEvent.endTime), + }); + + log.info("Nostr events published successfully", { eventId, blockId }); + + return { + uid: "", + id: eventId, + type: this.integrationName, + password: "", + url: "", + additionalInfo: { + blockId: blockId, + }, + }; + } catch (error) { + log.error("Error creating Nostr calendar event", error); + throw error; + } + } + + /** + * Update an existing calendar event + */ + async updateEvent( + uid: string, + event: CalendarServiceEvent, + _externalCalendarId: string + ): Promise { + log.debug("Updating Nostr calendar event", { uid }); + + // Nostr doesn't support updates - we need to delete and recreate + try { + const client = await this.getNostrClient(); + + // Delete old event + await client.deleteEvent(uid, "Event updated"); + + // Create new event + return await this.createEvent(event, this.credential.id); + } catch (error) { + log.error("Error updating Nostr calendar event", error); + throw error; + } + } + + /** + * Delete a calendar event and its availability block + */ + async deleteEvent(uid: string, event: CalendarEvent, _externalCalendarId?: string | null): Promise { + log.info("Deleting Nostr calendar event", { + uid, + startTime: event.startTime, + endTime: event.endTime, + }); + + try { + const client = await this.getNostrClient(); + + // Delete the main calendar event (kind 31923) + await client.deleteEvent(uid, "Event cancelled"); + + // Delete the associated availability block(s) (kind 31927) by time + await client.deleteAvailabilityBlocksByTime(new Date(event.startTime), new Date(event.endTime)); + + log.info("Nostr calendar event and availability block deleted successfully"); + } catch (error) { + log.error("Error deleting Nostr calendar event", error); + throw error; + } + } + + /** + * Get availability (busy times) from Nostr events + */ + async getAvailability( + dateFrom: string, + dateTo: string, + _selectedCalendars: IntegrationCalendar[] + ): Promise { + log.info("Getting availability from Nostr", { + dateFrom, + dateTo, + }); + + try { + const client = await this.getNostrClient(); + + const since = Math.floor(new Date(dateFrom).getTime() / 1000); + const until = Math.floor(new Date(dateTo).getTime() / 1000); + + // Query only public calendar events and availability blocks (31927) + // We don't need to check private events because we create 31927 blocks for those + const events = await client.queryPublicCalendarEvents(since, until); + + const parsedEvents = client.parseCalendarEvents(events); + log.info(`Parsed ${parsedEvents.length} events`, { + kinds: parsedEvents.map((e) => e.kind), + }); + + // Handle RSVPs - need to fetch parent events for timing + // Only look up public parent events since we're checking availability + const rsvps = parsedEvents.filter((e) => e.kind === 31925 && e.parentEventRef); + for (const rsvp of rsvps) { + if (rsvp.parentEventRef) { + // Parse the 'a' tag: "kind:pubkey:d-tag" + const [kindStr] = rsvp.parentEventRef.split(":"); + + // Query for the public parent event by kind, author, and d-tag + const parentEvents = await client.queryPublicCalendarEvents(undefined, undefined, [ + parseInt(kindStr) as 31922 | 31923, + ]); + + const parentEvent = parentEvents.find((e) => { + const tags = new Map(e.tags.map((tag) => [tag[0], tag.slice(1)])); + const dTag = tags.get("d")?.[0]; + return rsvp.parentEventRef?.endsWith(`:${dTag}`); + }); + + if (parentEvent) { + const parsed = client.parseCalendarEvents([parentEvent])[0]; + if (parsed) { + rsvp.start = parsed.start; + rsvp.end = parsed.end; + rsvp.timezone = parsed.timezone; + } + } + } + } + + // Convert to EventBusyDate format + const busyDates: EventBusyDate[] = parsedEvents + .filter((e) => { + // Include all events except declined RSVPs + if (e.kind === 31925 && e.status === "declined") { + log.debug(`Excluding declined RSVP`, { id: e.id }); + return false; + } + return true; + }) + .map((e) => { + const busySlot = { + start: e.start.toISOString(), + end: e.end.toISOString(), + }; + log.debug(`Adding busy slot`, { + kind: e.kind, + title: e.title, + start: busySlot.start, + end: busySlot.end, + }); + return busySlot; + }); + + log.info(`Returning ${busyDates.length} total busy time slots`); + return busyDates; + } catch (error) { + log.error("Error getting availability from Nostr", error); + throw error; + } + } + + /** + * List available calendars (Nostr only has one "calendar" per user) + */ + async listCalendars(): Promise { + const credentialKey = this.credential.key as NostrCredential; + + return [ + { + externalId: credentialKey.npub, + integration: this.integrationName, + name: credentialKey.displayName || "Nostr Calendar", + primary: true, + readOnly: false, + email: credentialKey.displayName || credentialKey.npub, + }, + ]; + } +} diff --git a/packages/app-store/nostrcalendar/lib/NostrClient.ts b/packages/app-store/nostrcalendar/lib/NostrClient.ts new file mode 100644 index 00000000000000..71fa6d39a604e1 --- /dev/null +++ b/packages/app-store/nostrcalendar/lib/NostrClient.ts @@ -0,0 +1,1068 @@ +import type { Event as NostrEvent, Filter, UnsignedEvent } from "nostr-tools"; +import { nip19, nip59, SimplePool, finalizeEvent, getPublicKey } from "nostr-tools"; +// Note: Using lib/types path for TypeScript, runtime resolves to lib/esm via package.json exports +import type { BunkerSigner } from "nostr-tools/lib/types/nip46"; + +import logger from "@calcom/lib/logger"; + +import type { CalendarEventKind, ParsedCalendarEvent, AuthType } from "./types"; + +const log = logger.getSubLogger({ prefix: ["nostr-client"] }); + +// Default relays used to bootstrap and discover user's kind 10002 relay list +const DEFAULT_RELAYS = [ + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.primal.net", +]; + +export class NostrClient { + private pool: SimplePool; + private defaultRelays: string[] = DEFAULT_RELAYS; + private relayListMetadata: string[] | null = null; // Cached kind 10002 relays + private authType: AuthType; + private secretKey?: Uint8Array; // Only for nsec auth + private bunker?: BunkerSigner; // Only for bunker auth + private publicKey?: string; // Cached public key + private publicKeyPromise?: Promise; // For async pubkey fetch with bunker + + constructor(auth: { nsec: string } | { bunker: BunkerSigner }) { + this.pool = new SimplePool(); + + if ("nsec" in auth) { + // nsec-based authentication + this.authType = "nsec"; + + // Decode nsec to get secret key + const decoded = nip19.decode(auth.nsec); + if (decoded.type !== "nsec") { + throw new Error("Invalid nsec key"); + } + + this.secretKey = decoded.data as Uint8Array; + this.publicKey = getPublicKey(this.secretKey); + } else { + // Bunker-based authentication + this.authType = "bunker"; + this.bunker = auth.bunker; + // Public key will be fetched lazily when needed + } + } + + /** + * Get the public key (handles both nsec and bunker auth) + */ + private async getPublicKey(): Promise { + if (this.publicKey) { + return this.publicKey; + } + + if (this.authType === "bunker" && this.bunker) { + // Fetch from bunker (cache the promise to avoid duplicate requests) + if (!this.publicKeyPromise) { + this.publicKeyPromise = this.bunker.getPublicKey(); + } + this.publicKey = await this.publicKeyPromise; + + // Type guard - this should never happen but satisfies TypeScript + if (!this.publicKey) { + throw new Error("Failed to retrieve public key from bunker"); + } + + return this.publicKey; + } + + throw new Error("Public key not available"); + } + + /** + * Create gift wraps manually using bunker for signing and encryption + * This is necessary because nip59.wrapManyEvents requires a private key + */ + private async createGiftWrapWithBunker( + eventTemplate: Partial, + recipients: string[] + ): Promise { + if (!this.bunker) { + throw new Error("Bunker not initialized"); + } + + const myPubkey = await this.getPublicKey(); + const wraps: NostrEvent[] = []; + + log.info("Creating gift wraps with bunker", { + recipients: recipients.length, + myPubkey: myPubkey.slice(0, 8), + }); + + // Create the rumor (unsigned event with proper structure) + // Note: We manually construct this rather than using nip59.createRumor + // because that function expects a private key parameter + const rumorBase: UnsignedEvent = { + ...eventTemplate, + pubkey: myPubkey, + created_at: eventTemplate.created_at || Math.floor(Date.now() / 1000), + kind: eventTemplate.kind!, + tags: eventTemplate.tags || [], + content: eventTemplate.content || "", + }; + + // Calculate rumor ID (same as regular event ID) + const serialized = JSON.stringify([ + 0, + rumorBase.pubkey, + rumorBase.created_at, + rumorBase.kind, + rumorBase.tags, + rumorBase.content, + ]); + const encoder = new TextEncoder(); + const data = encoder.encode(serialized); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const rumorId = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + + // Create rumor with id (typed as NostrEvent since it now has an id) + const rumor = { ...rumorBase, id: rumorId } as NostrEvent; + + log.debug("Created rumor", { rumorId: rumorId.slice(0, 8) }); + + // Create a gift wrap for each recipient + for (const recipientPubkey of recipients) { + try { + // Step 1: Encrypt rumor using bunker's nip44Encrypt + const encryptedRumor = await this.bunker.nip44Encrypt(recipientPubkey, JSON.stringify(rumor)); + + log.debug("Encrypted rumor for recipient", { + recipient: recipientPubkey.slice(0, 8), + encryptedLength: encryptedRumor.length, + }); + + // Step 2: Create seal template (kind 13) + // Randomize timestamp within 2 days in the past for privacy + const randomOffset = Math.floor(Math.random() * 172800); // 2 days in seconds + const sealTemplate: UnsignedEvent = { + kind: 13, + pubkey: myPubkey, // Seal is signed by the sender + content: encryptedRumor, + tags: [], + created_at: Math.floor(Date.now() / 1000) - randomOffset, + }; + + // Step 3: Sign seal using bunker + const signedSeal = await this.bunker.signEvent(sealTemplate); + + log.debug("Signed seal", { + sealId: signedSeal.id.slice(0, 8), + recipient: recipientPubkey.slice(0, 8), + }); + + // Step 4: Wrap the seal with a random key (client-side) + // This uses nip59.createWrap which generates an ephemeral key + const wrap = nip59.createWrap(signedSeal, recipientPubkey); + + wraps.push(wrap); + + log.debug("Created gift wrap", { + wrapId: wrap.id.slice(0, 8), + recipient: recipientPubkey.slice(0, 8), + }); + } catch (error) { + log.error("Failed to create gift wrap for recipient", { + recipient: recipientPubkey.slice(0, 8), + error, + }); + throw error; + } + } + + log.info(`Successfully created ${wraps.length} gift wraps with bunker`); + return wraps; + } + + /** + * Query kind 10002 (relay list metadata) for any user + * This is used for general calendar operations (read/write events) + */ + async queryRelayListMetadata(pubkey?: string): Promise { + const targetPubkey = pubkey || (await this.getPublicKey()); + + try { + log.info(`Querying kind 10002 relay list metadata for ${targetPubkey.slice(0, 8)}`); + + const event = await this.pool.get(this.defaultRelays, { + kinds: [10002], + authors: [targetPubkey], + }); + + if (!event) { + log.info(`No kind 10002 relay list found for ${targetPubkey.slice(0, 8)}`); + return []; + } + + // Parse relay tags - format: ["r", "relay-url", "read|write"] + // If marker is omitted, relay is used for both read and write + const relayTags = event.tags.filter((tag) => tag[0] === "r"); + const relays = relayTags + .map((tag) => tag[1]) + .filter((url): url is string => !!url && url.startsWith("wss://")); + + log.info(`Found kind 10002 with ${relays.length} relays for ${targetPubkey.slice(0, 8)}`, { + relays, + eventId: event.id.slice(0, 8), + }); + + return relays; + } catch (error) { + log.error("Error querying relay list metadata", error); + return []; + } + } + + /** + * Get relays for general calendar operations + * Lazily fetches and caches kind 10002 relay list on first call + * Falls back to default relays if kind 10002 not found + */ + async getRelays(): Promise { + if (this.relayListMetadata === null) { + const relays = await this.queryRelayListMetadata(); + this.relayListMetadata = relays.length > 0 ? relays : this.defaultRelays; + } + return this.relayListMetadata; + } + + /** + * Query kind 0 (user metadata/profile) to get display name + */ + async queryUserMetadata(pubkey?: string): Promise { + const targetPubkey = pubkey || (await this.getPublicKey()); + + try { + log.info(`Querying kind 0 metadata for ${targetPubkey.slice(0, 8)}`); + + const event = await this.pool.get(this.defaultRelays, { + kinds: [0], + authors: [targetPubkey], + }); + + if (!event) { + log.info(`No kind 0 metadata found for ${targetPubkey.slice(0, 8)}`); + return null; + } + + // Parse the content JSON which contains name, display_name, etc. + const metadata = JSON.parse(event.content); + const displayName = metadata.display_name || metadata.name || null; + + log.info(`Found display name for ${targetPubkey.slice(0, 8)}:`, { displayName }); + return displayName; + } catch (error) { + log.error("Error querying user metadata", error); + return null; + } + } + + /** + * Query kind 5 deletion events to get list of deleted event IDs + */ + async queryDeletionEvents(): Promise> { + try { + const relays = await this.getRelays(); + + log.info("Querying kind 5 deletion events"); + + // Query public deletion events + const publicDeletions = await this.pool.querySync(relays, { + kinds: [5], + authors: [await this.getPublicKey()], + }); + + log.info(`Found ${publicDeletions.length} public deletion events`); + + // Query private deletion events (gift-wrapped kind 5) + const privateDeletionEvents = await this.queryPrivateEvents([5]); + + log.info(`Found ${privateDeletionEvents.length} private deletion events`); + + // Combine all deletions + const allDeletions = [...publicDeletions, ...privateDeletionEvents]; + + // Extract deleted event IDs from 'e' tags + const deletedIds = new Set(); + for (const deletion of allDeletions) { + const eTags = deletion.tags.filter((tag) => tag[0] === "e"); + for (const tag of eTags) { + const eventId = tag[1]; + if (eventId) { + deletedIds.add(eventId); + log.debug(`Event ${eventId.slice(0, 8)} marked as deleted`); + } + } + } + + log.info(`Total ${deletedIds.size} events marked as deleted`); + return deletedIds; + } catch (error) { + log.error("Error querying deletion events", error); + return new Set(); + } + } + + /** + * Query ONLY public calendar events (for availability checking) + * Does NOT unwrap private events - use this for availability/busy time checks + * Private events have corresponding 31927 availability blocks instead + */ + async queryPublicCalendarEvents( + since?: number, + until?: number, + kinds: CalendarEventKind[] = [31922, 31923, 31925, 31927] + ): Promise { + const filter: Filter = { + kinds: kinds, + authors: [await this.getPublicKey()], + }; + + try { + const relays = await this.getRelays(); + + log.info("Querying public calendar events only (for availability)", { + kinds, + relays, + sinceDate: since ? new Date(since * 1000).toISOString() : undefined, + untilDate: until ? new Date(until * 1000).toISOString() : undefined, + }); + + // Query deletion events first to filter out deleted events + const deletedIds = await this.queryDeletionEvents(); + + // Query public events only (no private event unwrapping) + const publicEvents = await this.pool.querySync(relays, filter); + log.info(`Found ${publicEvents.length} public calendar events`); + + // Filter out deleted events + const events = publicEvents.filter((event) => { + if (deletedIds.has(event.id)) { + log.debug(`Excluding deleted event ${event.id.slice(0, 8)}`); + return false; + } + return true; + }); + + log.info(`${events.length} public events after filtering out ${deletedIds.size} deletions`); + + // Filter by actual event start times if date range specified + if (since !== undefined || until !== undefined) { + const filtered = events.filter((event) => { + const tags = new Map(event.tags.map((tag) => [tag[0], tag.slice(1)])); + const startTag = tags.get("start")?.[0]; + + if (!startTag) { + log.debug(`Event ${event.id.slice(0, 8)} has no start tag, excluding`); + return false; + } + + // Parse start timestamp (could be unix timestamp or ISO date) + const eventStart = startTag.includes("-") + ? Math.floor(new Date(startTag).getTime() / 1000) + : parseInt(startTag); + + const included = !( + (since !== undefined && eventStart < since) || + (until !== undefined && eventStart > until) + ); + + return included; + }); + log.info(`Filtered to ${filtered.length} public events in date range`); + return filtered; + } + + return events; + } catch (error) { + log.error("Error querying public calendar events", error); + throw error; + } + } + + /** + * Query calendar events from relays (including private events) + * Note: Fetches ALL events by author, then filters by actual event start/end times + * Also fetches private events (kind 1059 gift wraps) and unwraps them + * Filters out deleted events based on kind 5 deletion events + * Use queryPublicCalendarEvents() for availability checking instead + */ + async queryCalendarEvents( + since?: number, + until?: number, + kinds: CalendarEventKind[] = [31922, 31923, 31925, 31927] + ): Promise { + const filter: Filter = { + kinds: kinds, + authors: [await this.getPublicKey()], + }; + + try { + // Get relays from kind 10002 relay list metadata + const relays = await this.getRelays(); + + log.info("Querying calendar events", { + kinds, + relays, + sinceDate: since ? new Date(since * 1000).toISOString() : undefined, + untilDate: until ? new Date(until * 1000).toISOString() : undefined, + }); + + // Query deletion events first to filter out deleted events + const deletedIds = await this.queryDeletionEvents(); + + // Query public events + const publicEvents = await this.pool.querySync(relays, filter); + log.info(`Found ${publicEvents.length} public calendar events`); + + // Query private events (only calendar event kinds, exclude 31927 which is always public) + const privateKinds = kinds.filter((k) => k !== 31927); + let privateEvents: NostrEvent[] = []; + + if (privateKinds.length > 0) { + privateEvents = await this.queryPrivateEvents(privateKinds); + log.info(`Found ${privateEvents.length} private calendar events`); + } + + // Combine public and private events + const allEvents = [...publicEvents, ...privateEvents]; + log.info( + `Total ${allEvents.length} events before deletion filter (${publicEvents.length} public, ${privateEvents.length} private)` + ); + + // Filter out deleted events + const events = allEvents.filter((event) => { + if (deletedIds.has(event.id)) { + log.debug(`Excluding deleted event ${event.id.slice(0, 8)}`); + return false; + } + return true; + }); + + log.info(`${events.length} events after filtering out ${deletedIds.size} deletions`); + + // Log details of all events found + events.forEach((event) => { + const tags = new Map(event.tags.map((tag) => [tag[0], tag.slice(1)])); + const startTag = tags.get("start")?.[0]; + const endTag = tags.get("end")?.[0]; + const title = tags.get("title")?.[0]; + log.debug(`Event kind ${event.kind}`, { + id: event.id.slice(0, 8), + title, + start: startTag, + end: endTag, + createdAt: new Date(event.created_at * 1000).toISOString(), + }); + }); + + // Filter by actual event start times if date range specified + if (since !== undefined || until !== undefined) { + const filtered = events.filter((event) => { + const tags = new Map(event.tags.map((tag) => [tag[0], tag.slice(1)])); + const startTag = tags.get("start")?.[0]; + + if (!startTag) { + log.debug(`Event ${event.id.slice(0, 8)} has no start tag, excluding`); + return false; + } + + // Parse start timestamp (could be unix timestamp or ISO date) + const eventStart = startTag.includes("-") + ? Math.floor(new Date(startTag).getTime() / 1000) + : parseInt(startTag); + + const included = !( + (since !== undefined && eventStart < since) || + (until !== undefined && eventStart > until) + ); + + log.debug(`Event ${event.id.slice(0, 8)} ${included ? "INCLUDED" : "EXCLUDED"}`, { + eventStart: new Date(eventStart * 1000).toISOString(), + queryRange: { + since: since ? new Date(since * 1000).toISOString() : "none", + until: until ? new Date(until * 1000).toISOString() : "none", + }, + }); + + return included; + }); + log.info(`Filtered to ${filtered.length} events in date range`); + return filtered; + } + + return events; + } catch (error) { + log.error("Error querying calendar events", error); + throw error; + } + } + + /** + * Get a specific event by ID (for looking up parent events of RSVPs) + */ + async getEventById(eventId: string): Promise { + try { + const relays = await this.getRelays(); + const event = await this.pool.get(relays, { + ids: [eventId], + }); + return event; + } catch (error) { + log.error("Error fetching event by ID", error); + return null; + } + } + + /** + * Query kind 10050 (DM relay list) for a user to get their preferred relays for gift wraps + * Uses default relays to bootstrap the query + */ + async queryRelayList(pubkey: string): Promise { + try { + log.info(`Querying kind 10050 relay list for ${pubkey.slice(0, 8)}`); + + const event = await this.pool.get(this.defaultRelays, { + kinds: [10050], + authors: [pubkey], + }); + + if (!event) { + log.info(`No kind 10050 relay list found for ${pubkey.slice(0, 8)}, will use default relays`); + return []; + } + + const relayTags = event.tags.filter((tag) => tag[0] === "relay"); + const relays = relayTags.map((tag) => tag[1]).filter((url): url is string => !!url); + + log.info(`Found kind 10050 with ${relays.length} relays for ${pubkey.slice(0, 8)}`, { + relays, + eventId: event.id.slice(0, 8), + }); + return relays; + } catch (error) { + log.error("Error querying relay list", error); + return []; + } + } + + /** + * Query and unwrap private events from gift wraps (kind 1059) + * Uses kind 10050 relays if available, falls back to kind 10002, then default relays + * @param allowedKinds - Optional array of kinds to filter for. If not provided, returns all unwrapped events + */ + async queryPrivateEvents(allowedKinds?: number[]): Promise { + try { + const myPubkey = await this.getPublicKey(); + + // First, get own kind 10050 relay list + const privateRelays = await this.queryRelayList(myPubkey); + + // Fallback: kind 10050 → kind 10002 → default relays + let relaysToQuery: string[]; + if (privateRelays.length > 0) { + relaysToQuery = privateRelays; + } else { + const relayMetadata = await this.queryRelayListMetadata(myPubkey); + relaysToQuery = relayMetadata.length > 0 ? relayMetadata : this.defaultRelays; + } + + const source = + privateRelays.length > 0 + ? "kind 10050" + : relaysToQuery === this.defaultRelays + ? "default" + : "kind 10002"; + log.info(`Querying private events (kind 1059) from ${relaysToQuery.length} relays (${source}):`, { + relays: relaysToQuery, + recipientPubkey: myPubkey.slice(0, 8), + }); + + // Query kind 1059 gift wraps addressed to us + const giftWraps = await this.pool.querySync(relaysToQuery, { + kinds: [1059], + "#p": [myPubkey], + }); + + log.info(`Found ${giftWraps.length} gift wraps`, { + wrapIds: giftWraps.map((wrap) => wrap.id.slice(0, 8)), + }); + + // Unwrap each gift wrap to get the actual events + const unwrappedEvents: NostrEvent[] = []; + for (const wrap of giftWraps) { + try { + if (this.authType === "nsec" && this.secretKey) { + const rumor = nip59.unwrapEvent(wrap, this.secretKey); + + // Filter by allowed kinds if specified + if (!allowedKinds || allowedKinds.includes(rumor.kind)) { + unwrappedEvents.push(rumor as NostrEvent); + log.debug(`Unwrapped ${rumor.kind} event`, { + id: rumor.id.slice(0, 8), + kind: rumor.kind, + }); + } + } else if (this.authType === "bunker" && this.bunker) { + // For bunker auth, we need to decrypt the wrap manually + // This requires implementing manual unwrapping with bunker.nip44Decrypt + log.warn("Bunker-based unwrapping not yet implemented for received events"); + } + } catch (error) { + log.debug("Failed to unwrap gift wrap", { error }); + } + } + + log.info(`Unwrapped ${unwrappedEvents.length} private events`); + return unwrappedEvents; + } catch (error) { + log.error("Error querying private events", error); + return []; + } + } + + /** + * Parse calendar events into standardized format with timing info + */ + parseCalendarEvents(events: NostrEvent[]): ParsedCalendarEvent[] { + return events + .map((event) => { + try { + return this.parseEvent(event); + } catch (error) { + log.warn("Failed to parse event", { eventId: event.id, error }); + return null; + } + }) + .filter((e): e is ParsedCalendarEvent => e !== null); + } + + /** + * Parse a single event + */ + private parseEvent(event: NostrEvent): ParsedCalendarEvent | null { + const tags = new Map(event.tags.map((tag) => [tag[0], tag.slice(1)])); + const kind = event.kind as CalendarEventKind; + + switch (kind) { + case 31922: // Date-based event + return this.parseDateBasedEvent(event, tags); + case 31923: // Time-based event + return this.parseTimeBasedEvent(event, tags); + case 31925: // RSVP (requires parent lookup) + return this.parseRSVP(event, tags); + case 31927: // Availability block + return this.parseAvailabilityBlock(event, tags); + default: + return null; + } + } + + private parseDateBasedEvent(event: NostrEvent, tags: Map): ParsedCalendarEvent { + const startDate = tags.get("start")?.[0]; + const endDate = tags.get("end")?.[0] || startDate; + const title = tags.get("title")?.[0]; + + if (!startDate) throw new Error("Date-based event missing start date"); + + return { + id: event.id, + kind: 31922, + title, + start: new Date(startDate), + end: new Date(endDate!), + }; + } + + private parseTimeBasedEvent(event: NostrEvent, tags: Map): ParsedCalendarEvent { + const startTs = tags.get("start")?.[0]; + const endTs = tags.get("end")?.[0]; + const title = tags.get("title")?.[0]; + const timezone = tags.get("start_tzid")?.[0]; + + if (!startTs) throw new Error("Time-based event missing start timestamp"); + + return { + id: event.id, + kind: 31923, + title, + start: new Date(parseInt(startTs) * 1000), + end: endTs ? new Date(parseInt(endTs) * 1000) : new Date(parseInt(startTs) * 1000), + timezone, + }; + } + + private parseRSVP(event: NostrEvent, tags: Map): ParsedCalendarEvent | null { + const status = tags.get("status")?.[0] as "accepted" | "declined" | "tentative" | undefined; + const parentRef = tags.get("a")?.[0]; // Format: "kind:pubkey:d-tag" + + // Only mark as busy if accepted + if (status !== "accepted") { + return null; + } + + // For RSVPs, we need to look up the parent event to get timing + // This will be handled in CalendarService + return { + id: event.id, + kind: 31925, + status, + parentEventRef: parentRef, + start: new Date(0), // Placeholder - will be filled from parent event + end: new Date(0), // Placeholder + }; + } + + private parseAvailabilityBlock(event: NostrEvent, tags: Map): ParsedCalendarEvent { + const startTs = tags.get("start")?.[0]; + const endTs = tags.get("end")?.[0]; + + if (!startTs || !endTs) throw new Error("Availability block missing start or end"); + + return { + id: event.id, + kind: 31927, + start: new Date(parseInt(startTs) * 1000), + end: new Date(parseInt(endTs) * 1000), + }; + } + + /** + * Create and publish a time-based calendar event (kind 31923) + * Private events use NIP-59 gift wrapping to encrypt and send to participants + */ + async createCalendarEvent(params: { + title: string; + description: string; + startTime: Date; + endTime: Date; + timezone: string; + location?: string; + attendees?: string[]; // npub strings + private?: boolean; // Default to true for privacy + }): Promise { + const isPrivate = params.private ?? true; // Default to private + + const eventTemplate: Partial = { + kind: 31923 as const, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["d", crypto.randomUUID()], // Unique identifier + ["title", params.title], + ["start", Math.floor(params.startTime.getTime() / 1000).toString()], + ["end", Math.floor(params.endTime.getTime() / 1000).toString()], + ["start_tzid", params.timezone], + ["end_tzid", params.timezone], + ] as [string, ...string[]][], + content: params.description, + }; + + // Add optional tags + if (params.location) { + eventTemplate.tags!.push(["location", params.location]); + } + + // Parse attendees and add p tags + const participantPubkeys: string[] = []; + const myPubkey = await this.getPublicKey(); + + // Always add organizer (self) as a participant for private events + if (isPrivate) { + eventTemplate.tags!.push(["p", myPubkey, "", "organizer"]); + // Note: wrapManyEvents automatically includes sender, so we don't add to participantPubkeys + } + + if (params.attendees) { + for (const attendee of params.attendees) { + try { + const decoded = nip19.decode(attendee); + if (decoded.type === "npub") { + const pubkey = decoded.data as string; + // Only add if not already the organizer + if (pubkey !== myPubkey) { + eventTemplate.tags!.push(["p", pubkey, "", "required"]); + participantPubkeys.push(pubkey); + } + } + } catch { + log.warn("Invalid npub for attendee", attendee); + } + } + } + + if (isPrivate) { + // Private event: use NIP-59 gift wrapping + log.info("Creating private calendar event with gift wrapping", { + participants: participantPubkeys.length, + organizer: myPubkey.slice(0, 8), + }); + + // Log the unsigned event being wrapped + log.info("Unsigned event to be wrapped (rumor):", { + kind: eventTemplate.kind, + created_at: eventTemplate.created_at, + tags: eventTemplate.tags, + content: eventTemplate.content, + }); + + try { + // wrapManyEvents requires at least one recipient + // If no external participants (self-booking), pass [self] to satisfy the requirement + const recipients = participantPubkeys.length > 0 ? participantPubkeys : [myPubkey]; + + log.info(`Creating gift wraps for recipients:`, { + recipients: recipients.map((r) => r.slice(0, 8)), + }); + + // Create gift wraps - different method for bunker vs nsec + let giftWraps: NostrEvent[]; + + if (this.authType === "bunker" && this.bunker) { + // Use manual gift wrapping for bunker + giftWraps = await this.createGiftWrapWithBunker(eventTemplate, recipients); + } else if (this.authType === "nsec" && this.secretKey) { + // Use standard nip59 for nsec + giftWraps = nip59.wrapManyEvents(eventTemplate, this.secretKey, recipients); + } else { + throw new Error("No valid authentication method available"); + } + + log.info(`Created ${giftWraps.length} gift wraps`); + + // Publish each gift wrap to the appropriate relay + const publishPromises: Promise[] = []; + + for (let i = 0; i < giftWraps.length; i++) { + const wrap = giftWraps[i]; + const pTag = wrap.tags.find(([key]) => key === "p"); + const recipientPubkey = pTag?.[1]; + + if (!recipientPubkey) continue; + + log.info(`Gift wrap ${i + 1}/${giftWraps.length} details:`, { + wrapId: wrap.id.slice(0, 8), + kind: wrap.kind, + recipient: recipientPubkey.slice(0, 8), + created_at: wrap.created_at, + pubkey: wrap.pubkey.slice(0, 8), + tags: wrap.tags, + }); + + // Query recipient's kind 10050 relay list + const recipientRelays = await this.queryRelayList(recipientPubkey); + + // Fallback: kind 10050 → kind 10002 → default relays + let targetRelays: string[]; + let relaySource: string; + if (recipientRelays.length > 0) { + targetRelays = recipientRelays; + relaySource = "kind 10050"; + } else { + const recipientMetadata = await this.queryRelayListMetadata(recipientPubkey); + if (recipientMetadata.length > 0) { + targetRelays = recipientMetadata; + relaySource = "kind 10002"; + } else { + targetRelays = this.defaultRelays; + relaySource = "default"; + } + } + + log.info( + `Publishing gift wrap ${i + 1} to ${ + targetRelays.length + } relays (${relaySource}) for ${recipientPubkey.slice(0, 8)}:`, + { + relays: targetRelays, + } + ); + + // Publish to recipient's relays + publishPromises.push( + Promise.any(this.pool.publish(targetRelays, wrap)) + .then(() => { + log.info(`✓ Gift wrap ${i + 1} delivered successfully to ${recipientPubkey.slice(0, 8)}`); + }) + .catch((err) => { + log.warn(`✗ Failed to deliver gift wrap ${i + 1} to ${recipientPubkey.slice(0, 8)}`, err); + }) + ); + } + + // Wait for all publishes to complete + await Promise.allSettled(publishPromises); + + log.info("Private calendar event published successfully"); + + // Return the rumor's ID by unwrapping the first gift wrap (sent to ourselves) + if (this.authType === "nsec" && this.secretKey) { + const selfWrap = giftWraps[0]; // First one is always for sender + const rumor = nip59.unwrapEvent(selfWrap, this.secretKey); + return rumor.id; + } else { + // For bunker, we can't unwrap easily, so generate a deterministic ID + // This is acceptable since the event was published + const tempId = crypto.randomUUID(); + log.info("Generated temp ID for bunker event", { tempId }); + return tempId; + } + } catch (error) { + log.error("Error creating private calendar event", error); + throw error; + } + } else { + // Public event: sign and publish normally + let signedEvent: NostrEvent; + + if (this.authType === "bunker" && this.bunker) { + signedEvent = await this.bunker.signEvent(eventTemplate as UnsignedEvent); + } else if (this.authType === "nsec" && this.secretKey) { + signedEvent = finalizeEvent(eventTemplate as UnsignedEvent, this.secretKey); + } else { + throw new Error("No valid authentication method available"); + } + + try { + const relays = await this.getRelays(); + log.debug("Publishing public calendar event", { eventId: signedEvent.id, relays }); + await Promise.any(this.pool.publish(relays, signedEvent)); + log.debug("Public calendar event published successfully"); + return signedEvent.id; + } catch (error) { + log.error("Error publishing public calendar event", error); + throw error; + } + } + } + + /** + * Create and publish an availability block (kind 31927) + */ + async createAvailabilityBlock(params: { startTime: Date; endTime: Date }): Promise { + const eventTemplate = { + kind: 31927 as const, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["d", crypto.randomUUID()], + ["start", Math.floor(params.startTime.getTime() / 1000).toString()], + ["end", Math.floor(params.endTime.getTime() / 1000).toString()], + ] as [string, ...string[]][], + content: "", + }; + + let signedEvent: NostrEvent; + + if (this.authType === "bunker" && this.bunker) { + signedEvent = await this.bunker.signEvent(eventTemplate); + } else if (this.authType === "nsec" && this.secretKey) { + signedEvent = finalizeEvent(eventTemplate, this.secretKey); + } else { + throw new Error("No valid authentication method available"); + } + + try { + const relays = await this.getRelays(); + log.debug("Publishing availability block", { eventId: signedEvent.id, relays }); + await Promise.any(this.pool.publish(relays, signedEvent)); + log.debug("Availability block published successfully"); + return signedEvent.id; + } catch (error) { + log.error("Error publishing availability block", error); + throw error; + } + } + + /** + * Delete an event (NIP-09) + */ + async deleteEvent(eventId: string, reason?: string): Promise { + const eventTemplate = { + kind: 5 as const, + created_at: Math.floor(Date.now() / 1000), + tags: [["e", eventId]] as [string, ...string[]][], + content: reason || "", + }; + + let signedEvent: NostrEvent; + + if (this.authType === "bunker" && this.bunker) { + signedEvent = await this.bunker.signEvent(eventTemplate); + } else if (this.authType === "nsec" && this.secretKey) { + signedEvent = finalizeEvent(eventTemplate, this.secretKey); + } else { + throw new Error("No valid authentication method available"); + } + + try { + const relays = await this.getRelays(); + log.debug("Publishing deletion event", { targetEventId: eventId, relays }); + await Promise.any(this.pool.publish(relays, signedEvent)); + log.debug("Deletion event published successfully"); + } catch (error) { + log.error("Error publishing deletion event", error); + throw error; + } + } + + /** + * Delete availability blocks (kind 31927) matching specific start/end times + */ + async deleteAvailabilityBlocksByTime(startTime: Date, endTime: Date): Promise { + try { + const relays = await this.getRelays(); + + const startTs = Math.floor(startTime.getTime() / 1000).toString(); + const endTs = Math.floor(endTime.getTime() / 1000).toString(); + + log.info("Querying availability blocks to delete", { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }); + + // Query all kind 31927 availability blocks by this user + const blocks = await this.pool.querySync(relays, { + kinds: [31927], + authors: [await this.getPublicKey()], + }); + + log.debug(`Found ${blocks.length} availability blocks to check`); + + // Find blocks matching the exact start/end times + const matchingBlocks = blocks.filter((block) => { + const tags = new Map(block.tags.map((tag) => [tag[0], tag.slice(1)])); + const blockStart = tags.get("start")?.[0]; + const blockEnd = tags.get("end")?.[0]; + + return blockStart === startTs && blockEnd === endTs; + }); + + log.info(`Found ${matchingBlocks.length} matching availability blocks to delete`); + + // Delete each matching block + for (const block of matchingBlocks) { + await this.deleteEvent(block.id, "Availability block cancelled"); + log.debug(`Deleted availability block ${block.id.slice(0, 8)}`); + } + } catch (error) { + log.error("Error deleting availability blocks by time", error); + throw error; + } + } + + /** + * Close all relay connections + */ + close(): void { + const allRelays = this.relayListMetadata + ? Array.from(new Set([...this.defaultRelays, ...this.relayListMetadata])) + : this.defaultRelays; + this.pool.close(allRelays); + } +} diff --git a/packages/app-store/nostrcalendar/lib/index.ts b/packages/app-store/nostrcalendar/lib/index.ts new file mode 100644 index 00000000000000..8b6b8b161e5f49 --- /dev/null +++ b/packages/app-store/nostrcalendar/lib/index.ts @@ -0,0 +1,3 @@ +export { default as CalendarService } from "./CalendarService"; +export * from "./NostrClient"; +export * from "./types"; diff --git a/packages/app-store/nostrcalendar/lib/types.ts b/packages/app-store/nostrcalendar/lib/types.ts new file mode 100644 index 00000000000000..0d7bcdeebd71b1 --- /dev/null +++ b/packages/app-store/nostrcalendar/lib/types.ts @@ -0,0 +1,47 @@ +import type { Event as NostrEvent } from "nostr-tools"; +// Note: Using lib/types path for TypeScript, runtime resolves to lib/esm via package.json exports +import type { BunkerSigner } from "nostr-tools/lib/types/nip46"; + +// Authentication types +export type AuthType = "nsec" | "bunker"; + +// Bunker connection information +export interface BunkerConnection { + bunker: BunkerSigner; + clientSecret: Uint8Array; +} + +// NIP-52 Calendar Event Types +export type CalendarEventKind = 31922 | 31923 | 31925 | 31927; + +// Date-based calendar event (kind 31922) +export interface DateBasedCalendarEvent extends NostrEvent { + kind: 31922; +} + +// Time-based calendar event (kind 31923) +export interface TimeBasedCalendarEvent extends NostrEvent { + kind: 31923; +} + +// Calendar RSVP (kind 31925) +export interface CalendarRSVP extends NostrEvent { + kind: 31925; +} + +// Calendar availability block (kind 31927) +export interface CalendarAvailabilityBlock extends NostrEvent { + kind: 31927; +} + +// Parsed calendar event with timing info +export interface ParsedCalendarEvent { + id: string; + kind: CalendarEventKind; + title?: string; + start: Date; + end: Date; + timezone?: string; + status?: "accepted" | "declined" | "tentative"; + parentEventRef?: string; // For RSVPs +} diff --git a/packages/app-store/nostrcalendar/package.json b/packages/app-store/nostrcalendar/package.json new file mode 100644 index 00000000000000..19b99f2fd5c8d4 --- /dev/null +++ b/packages/app-store/nostrcalendar/package.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/nostrcalendar", + "version": "0.0.0", + "main": "./index.ts", + "dependencies": { + "@calcom/lib": "workspace:*", + "nostr-tools": "^2.17.0" + }, + "devDependencies": { + "@calcom/types": "workspace:*" + }, + "description": "Sync your calendar with Nostr (NIP-52) events" +} diff --git a/packages/app-store/nostrcalendar/pages/setup/_getServerSideProps.tsx b/packages/app-store/nostrcalendar/pages/setup/_getServerSideProps.tsx new file mode 100644 index 00000000000000..1531cbc5ac1fda --- /dev/null +++ b/packages/app-store/nostrcalendar/pages/setup/_getServerSideProps.tsx @@ -0,0 +1,21 @@ +import type { GetServerSidePropsContext } from "next"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const { req } = ctx; + const session = await getServerSession({ req }); + + if (!session?.user?.id) { + return { + redirect: { + permanent: false, + destination: "/auth/login", + }, + }; + } + + return { + props: {}, + }; +}; diff --git a/packages/app-store/nostrcalendar/static/1.jpeg b/packages/app-store/nostrcalendar/static/1.jpeg new file mode 100644 index 00000000000000..63a310fec602cd Binary files /dev/null and b/packages/app-store/nostrcalendar/static/1.jpeg differ diff --git a/packages/app-store/nostrcalendar/static/2.jpeg b/packages/app-store/nostrcalendar/static/2.jpeg new file mode 100644 index 00000000000000..8ad640dc51de86 Binary files /dev/null and b/packages/app-store/nostrcalendar/static/2.jpeg differ diff --git a/packages/app-store/nostrcalendar/static/3.jpeg b/packages/app-store/nostrcalendar/static/3.jpeg new file mode 100644 index 00000000000000..46ef5eee4df6a4 Binary files /dev/null and b/packages/app-store/nostrcalendar/static/3.jpeg differ diff --git a/packages/app-store/nostrcalendar/static/icon.svg b/packages/app-store/nostrcalendar/static/icon.svg new file mode 100644 index 00000000000000..94e1f8a348f81c --- /dev/null +++ b/packages/app-store/nostrcalendar/static/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app-store/nostrcalendar/zod.ts b/packages/app-store/nostrcalendar/zod.ts new file mode 100644 index 00000000000000..aa9e577a50602f --- /dev/null +++ b/packages/app-store/nostrcalendar/zod.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +// Schema for data stored in the credential - discriminated union for nsec vs bunker auth +export const nostrCredentialSchema = z.discriminatedUnion("authType", [ + z.object({ + authType: z.literal("nsec"), + nsec: z.string().min(1), // Encrypted nsec key + npub: z.string().min(1), // Public key in bech32 format + displayName: z.string().optional(), // User's display name from kind 0 metadata + // Relays are discovered from kind 10002 relay list metadata + }), + z.object({ + authType: z.literal("bunker"), + bunkerUri: z.string().min(1), // Bunker connection URI (bunker://) + localClientSecret: z.string().min(1), // Encrypted client secret for reconnection + npub: z.string().min(1), // Public key in bech32 format + displayName: z.string().optional(), // User's display name from kind 0 metadata + }), +]); + +export type NostrCredential = z.infer; + +// Required by app-store build system +export const appDataSchema = z.object({}); + +export const appKeysSchema = z.object({}); diff --git a/yarn.lock b/yarn.lock index 53001fc5f00564..9f553942690394 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3712,6 +3712,16 @@ __metadata: languageName: unknown linkType: soft +"@calcom/nostrcalendar@workspace:packages/app-store/nostrcalendar": + version: 0.0.0-use.local + resolution: "@calcom/nostrcalendar@workspace:packages/app-store/nostrcalendar" + dependencies: + "@calcom/lib": "workspace:*" + "@calcom/types": "workspace:*" + nostr-tools: ^2.17.0 + languageName: unknown + linkType: soft + "@calcom/office365calendar@workspace:packages/app-store/office365calendar": version: 0.0.0-use.local resolution: "@calcom/office365calendar@workspace:packages/app-store/office365calendar" @@ -37563,7 +37573,27 @@ __metadata: languageName: node linkType: hard -"nostr-wasm@npm:v0.1.0": +"nostr-tools@npm:^2.17.0": + version: 2.17.0 + resolution: "nostr-tools@npm:2.17.0" + dependencies: + "@noble/ciphers": ^0.5.1 + "@noble/curves": 1.2.0 + "@noble/hashes": 1.3.1 + "@scure/base": 1.1.1 + "@scure/bip32": 1.3.1 + "@scure/bip39": 1.2.1 + nostr-wasm: 0.1.0 + peerDependencies: + typescript: ">=5.0.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 79d604d2b3431d122df605a56b3b5aba61db7d3f6721c907ac9ff32c7778976c7394e598f34416e66317d752eeceddf8978775f0a752931aab6afe4f7ab99a43 + languageName: node + linkType: hard + +"nostr-wasm@npm:0.1.0, nostr-wasm@npm:v0.1.0": version: 0.1.0 resolution: "nostr-wasm@npm:0.1.0" checksum: c0b404912d47c98efa69b4838a5bb7b2094c869b1aa2a96359c978c911878e9b37228296ef6432254815c9d15cdeebaa16b6d45106db4f3673bb5a6a00cc669c