Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/components/apps/AppSetupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
179 changes: 179 additions & 0 deletions apps/web/components/apps/nostrcalendar/Setup.tsx
Original file line number Diff line number Diff line change
@@ -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<FormData>({
defaultValues: {
authMethod: "bunker", // Default to bunker as recommended
bunkerUri: "",
nsec: "",
},
});

const [errorMessage, setErrorMessage] = useState("");
const authMethod = form.watch("authMethod");

return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div>
<Image
src="/api/app-store/nostrcalendar/icon.svg"
alt="Nostr"
width={48}
height={48}
className="h-12 w-12 max-w-2xl"
/>
</div>
<div className="flex w-10/12 flex-col">
<h1 className="text-default">Connect to Nostr</h1>
<div className="mt-1 text-sm">
Choose how you want to authenticate with Nostr for managing your calendar events.
</div>

<div className="my-2 mt-3">
<Form
form={form}
handleSubmit={async (values) => {
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);
}
}}>
<fieldset className="space-y-4" disabled={form.formState.isSubmitting}>
{/* Authentication Method Selection */}
<div className="space-y-3">
<label className="text-default text-sm font-medium">Authentication Method</label>

<label className="border-default hover:bg-emphasis flex cursor-pointer items-start space-x-3 rounded-md border p-3 transition-colors">
<input type="radio" value="bunker" {...form.register("authMethod")} className="mt-1" />
<div className="flex-1">
<div className="text-default font-medium">
Bunker Connection{" "}
<span className="bg-success text-success ml-2 rounded px-2 py-0.5 text-xs font-medium">
Recommended
</span>
</div>
<div className="text-subtle mt-1 text-sm">
Keep your keys secure in a remote signer. Supports all features including private
events.
</div>
</div>
</label>

<label className="border-default hover:bg-emphasis flex cursor-pointer items-start space-x-3 rounded-md border p-3 transition-colors">
<input type="radio" value="nsec" {...form.register("authMethod")} className="mt-1" />
<div className="flex-1">
<div className="text-default font-medium">Private Key (nsec)</div>
<div className="text-subtle mt-1 text-sm">
Store encrypted key locally. Supports all features including private events.
</div>
</div>
</label>
</div>

{/* Conditional Input Fields */}
{authMethod === "bunker" ? (
<>
<TextField
required
{...form.register("bunkerUri")}
label="Bunker URI"
placeholder="bunker://... or user@domain.com"
autoComplete="off"
/>

<div className="rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Icon name="info" className="mb-0.5 inline-flex h-4 w-4" /> Your keys stay secure in
the bunker. You&apos;ll need to approve the connection in your bunker app.
</div>

<details className="text-subtle text-sm">
<summary className="cursor-pointer font-medium">
What permissions will be requested?
</summary>
<ul className="mt-2 space-y-1 pl-5">
<li>• Sign calendar events (kinds 31922, 31923, 31927)</li>
<li>• Sign seals for private events (kind 13)</li>
<li>• Sign deletion events (kind 5)</li>
<li>• Encrypt/decrypt content (NIP-44)</li>
</ul>
</details>
</>
) : (
<>
<TextField
required
type="password"
{...form.register("nsec")}
label="Nostr Private Key (nsec)"
placeholder="nsec1..."
autoComplete="off"
/>

<div className="rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Icon name="info" className="mb-0.5 inline-flex h-4 w-4" /> Your nsec key will be
encrypted before storage. Never share this key with anyone.
</div>
</>
)}

<div className="rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Icon name="info" className="mb-0.5 inline-flex h-4 w-4" /> Your relay list will be
automatically discovered from your kind 10002 relay list metadata.
</div>
</fieldset>

{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}

<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</div>
</Form>
</div>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}
1 change: 1 addition & 0 deletions packages/app-store/_pages/setup/_getServerSideProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.keys-schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.metadata.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/apps.server.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/calendar.services.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
};
52 changes: 52 additions & 0 deletions packages/app-store/nostrcalendar/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 23 additions & 0 deletions packages/app-store/nostrcalendar/_metadata.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +5 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

CRITICAL: Fix slug inconsistency.

There is a critical inconsistency in the slug definition:

  • config.json (Line 4): "slug": "nostrcalendar"
  • _metadata.ts (Line 16): slug: "nostr"

This mismatch will cause the app store to fail resolving the integration properly. The slug must be identical across all configuration files.

Additionally, there's a URL inconsistency:

  • config.json (Line 8): "url": "https://nostrcal.com"
  • _metadata.ts (Line 17): url: "https://nostr.com"

Apply this diff to align the slug with config.json and fix the URL:

 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",
+  slug: "nostrcalendar",
+  url: "https://nostrcal.com",
   email: "hello@nostrcal.com",
   dirName: "nostrcalendar",
   isOAuth: false,
 } as AppMeta;
🤖 Prompt for AI Agents
In packages/app-store/nostrcalendar/_metadata.ts around lines 5 to 21, the slug
and url values are inconsistent with config.json (slug should be "nostrcalendar"
and url should be "https://nostrcal.com"); update the slug from "nostr" to
"nostrcalendar" and the url from "https://nostr.com" to "https://nostrcal.com"
so both files match exactly, and verify the same values are used across any
other related config files.


export default metadata;
Loading
Loading