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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL="La Crypta Dev <login@lacrypta.dev>"

# ─── Events subscription (optional) ─────────────────────────────────────────

# Upstream La Crypta CRM subscribe endpoint that backs POST /api/events-subscribe.
# Server-only proxy target. Defaults to https://events.lacrypta.ar/api/subscribe
# when unset. Read by: app/api/events-subscribe/route.ts.
# EVENTS_SUBSCRIBE_URL=https://events.lacrypta.ar/api/subscribe

# CRM contact list UUID(s) to subscribe contacts into (comma-separated for
# several). Forwarded as the `lists` array to the subscribe endpoint. Copy IDs
# from the CRM at /dashboard/lists. Defaults to the main newsletter list.
EVENTS_SUBSCRIBE_LISTS=0135a251-8a46-4f88-b5bc-315d982eb7fa

# ─── Cache revalidation (required for /api/revalidate-nostr) ────────────────

# Shared secret that gates POST /api/revalidate-nostr.
Expand Down
150 changes: 150 additions & 0 deletions app/api/events-subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import { isValidEmail, normalizeEmail } from "@/lib/emailLogin";

const DEFAULT_ENDPOINT = "https://events.lacrypta.ar/api/subscribe";

/** Default CRM contact list to subscribe contacts into (see API_SUBSCRIBE docs). */
const DEFAULT_LIST_IDS = "0135a251-8a46-4f88-b5bc-315d982eb7fa";

/** Parse the comma-separated list-id env var into a clean UUID array. */
function getListIds(): string[] {
const raw = process.env.EVENTS_SUBSCRIBE_LISTS?.trim() || DEFAULT_LIST_IDS;
return raw
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}

type SubscribeBody = {
email?: string;
npub?: string;
name?: string;
phone?: string;
};

type CrmResponse = {
ok?: boolean;
exists?: boolean;
message?: string;
error?: string;
};

function jsonError(message: string, status = 400) {
return NextResponse.json({ error: message }, { status });
}

function isHexPubkey(value: string): boolean {
return /^[0-9a-f]{64}$/iu.test(value);
}

/**
* The events CRM expects a bech32 `npub1…` string (it decodes it to hex on
* its side). Accept either an `npub1…` or a raw 64-char hex pubkey and always
* forward the bech32 form. Returns "" when nothing usable was provided.
*/
async function normalizeNpub(raw: string): Promise<string> {
const value = raw.trim().toLowerCase();
if (!value) return "";
const { decode, npubEncode } = await import("nostr-tools/nip19");
if (isHexPubkey(value)) return npubEncode(value);
if (!value.startsWith("npub1")) throw new Error("npub invalida.");
try {
const decoded = decode(value);
if (decoded.type !== "npub") throw new Error("npub invalida.");
return npubEncode(decoded.data as string);
} catch {
throw new Error("npub invalida.");
}
}

export async function POST(req: Request) {
let body: SubscribeBody;
try {
body = (await req.json()) as SubscribeBody;
} catch {
return jsonError("Body JSON invalido.");
}

const email = normalizeEmail(body.email ?? "");
if (body.email && !isValidEmail(email)) {
return jsonError("Correo electronico invalido.");
}

let npub = "";
try {
npub = await normalizeNpub(body.npub ?? "");
} catch (error) {
return jsonError(error instanceof Error ? error.message : "npub invalida.");
}

if (!email && !npub) {
return jsonError("Envia email, npub o ambos.");
}

const endpoint = process.env.EVENTS_SUBSCRIBE_URL?.trim() || DEFAULT_ENDPOINT;
const name = body.name?.trim();
const phone = body.phone?.trim();
const lists = getListIds();

// Fields shared across every attempt; only the identity (email/npub) varies.
const baseFields = {
...(name ? { name } : {}),
...(phone ? { phone } : {}),
...(lists.length ? { lists } : {}),
};

async function subscribe(identity: { email?: string; npub?: string }) {
const res = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...identity, ...baseFields }),
});
const data = (await res.json().catch(() => ({}))) as CrmResponse;
return { res, data };
}

try {
let { res, data } = await subscribe({
...(email ? { email } : {}),
...(npub ? { npub } : {}),
});

// The CRM returns `identity_conflict` when email + npub are sent together
// but already belong to existing/different contacts (it never links them).
// Fall back to subscribing each identifier on its own — preferring the
// email — so the contact still gets added to the list.
if (
res.status === 409 &&
data.error === "identity_conflict" &&
email &&
npub
) {
for (const identity of [{ email }, { npub }]) {
const retry = await subscribe(identity);
if (retry.res.ok) {
res = retry.res;
data = retry.data;
break;
}
}
}

if (!res.ok) {
const message =
data.error || data.message || "No se pudo crear la suscripcion.";
// Surface the upstream status (e.g. 409 identity_conflict) to the client.
return jsonError(message, res.status >= 400 ? res.status : 502);
}

return NextResponse.json({
ok: true,
exists: Boolean(data.exists),
message: data.message ?? (data.exists ? "Already subscribed" : "Subscribed"),
});
} catch (error) {
return jsonError(
error instanceof Error ? error.message : "No se pudo crear la suscripcion.",
502,
);
}
}
46 changes: 37 additions & 9 deletions app/api/lnurl-invoice/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ type InvoiceRequestBody = {
endpoint?: unknown;
amount?: unknown;
nostr?: unknown;
comment?: unknown;
};

function isAllowedEndpoint(endpoint: string): boolean {
Expand All @@ -25,10 +26,19 @@ function isAllowedEndpoint(endpoint: string): boolean {
}
}

async function resolveInvoiceCallback(endpoint: string): Promise<string> {
type ResolvedCallback = {
callback: string;
/** Max comment length the service accepts (LUD-12), or null if unknown. */
commentAllowed: number | null;
};

async function resolveInvoiceCallback(
endpoint: string,
): Promise<ResolvedCallback> {
const url = new URL(endpoint);
if (!url.pathname.toLowerCase().includes("/.well-known/lnurlp/")) {
return endpoint;
// Already a callback URL (e.g. a NIP-57 zap endpoint) — pass through.
return { callback: endpoint, commentAllowed: null };
}

const metadataRes = await fetch(endpoint, {
Expand All @@ -39,6 +49,7 @@ async function resolveInvoiceCallback(endpoint: string): Promise<string> {
callback?: unknown;
status?: unknown;
reason?: unknown;
commentAllowed?: unknown;
};
if (!metadataRes.ok || metadata.status === "ERROR") {
throw new Error(
Expand All @@ -50,7 +61,13 @@ async function resolveInvoiceCallback(endpoint: string): Promise<string> {
if (typeof metadata.callback !== "string" || !isAllowedEndpoint(metadata.callback)) {
throw new Error("La lightning address no devolvió un callback válido.");
}
return metadata.callback;
return {
callback: metadata.callback,
commentAllowed:
typeof metadata.commentAllowed === "number"
? metadata.commentAllowed
: null,
};
}

export async function POST(req: Request) {
Expand All @@ -67,23 +84,24 @@ export async function POST(req: Request) {
const endpoint = typeof body.endpoint === "string" ? body.endpoint : "";
const amount = typeof body.amount === "string" ? body.amount : "";
const nostr = typeof body.nostr === "string" ? body.nostr : "";
const comment = typeof body.comment === "string" ? body.comment : "";

if (!isAllowedEndpoint(endpoint)) {
return NextResponse.json(
{ ok: false, reason: "Endpoint LNURL inválido." },
{ status: 400 },
);
}
if (!/^\d+$/.test(amount) || !nostr) {
if (!/^\d+$/.test(amount)) {
return NextResponse.json(
{ ok: false, reason: "Faltan amount o nostr." },
{ ok: false, reason: "Falta amount." },
{ status: 400 },
);
}

let callback: string;
let resolved: ResolvedCallback;
try {
callback = await resolveInvoiceCallback(endpoint);
resolved = await resolveInvoiceCallback(endpoint);
} catch (error) {
return NextResponse.json(
{
Expand All @@ -94,9 +112,19 @@ export async function POST(req: Request) {
);
}

const url = new URL(callback);
const url = new URL(resolved.callback);
url.searchParams.set("amount", amount);
url.searchParams.set("nostr", nostr);
if (nostr) {
// NIP-57 zap: the signed zap request carries the comment in its content.
url.searchParams.set("nostr", nostr);
} else if (comment) {
// Plain LNURL-pay (LUD-12): include the comment within the allowed length.
const max = resolved.commentAllowed;
const trimmed = max && max > 0 ? comment.slice(0, max) : comment;
if (max === null || max > 0) {
url.searchParams.set("comment", trimmed);
}
}

try {
const invoiceRes = await fetch(url.toString(), {
Expand Down
Loading