From fd0c4376824b5b93e532d9f458504f2387c78099 Mon Sep 17 00:00:00 2001 From: hafiz-ahtasham-ali Date: Fri, 20 Feb 2026 15:39:56 +0500 Subject: [PATCH 01/32] Add static WhatsApp account linking UI to Connected Accounts section --- website/app/account/page.tsx | 246 ++++++++++++++++++++++++++++++----- 1 file changed, 213 insertions(+), 33 deletions(-) diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index 475abd5e..a20174cf 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -23,6 +23,12 @@ export default function AccountPage() { const [confirmPassword, setConfirmPassword] = useState(""); const [passwordLoading, setPasswordLoading] = useState(false); const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [showWhatsAppModal, setShowWhatsAppModal] = useState(false); + const [whatsAppCode, setWhatsAppCode] = useState(""); + const [whatsAppConnected, setWhatsAppConnected] = useState(false); + const [whatsAppLinking, setWhatsAppLinking] = useState(false); + const [whatsAppPhone, setWhatsAppPhone] = useState(""); + const [whatsAppMessage, setWhatsAppMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); React.useEffect(() => { if (user) { @@ -118,6 +124,36 @@ export default function AccountPage() { } }; + const handleWhatsAppLink = async (e: React.FormEvent) => { + e.preventDefault(); + if (!whatsAppCode.trim()) return; + + setWhatsAppLinking(true); + setWhatsAppMessage(null); + + // Static simulation — pretend to verify the code + setTimeout(() => { + if (whatsAppCode.trim().length >= 4 && whatsAppCode.trim() !== "000000") { + setWhatsAppConnected(true); + setWhatsAppPhone("+92 3XX XXXXXXX"); + setWhatsAppMessage({ type: "success", text: "WhatsApp account linked successfully!" }); + setTimeout(() => { + setShowWhatsAppModal(false); + setWhatsAppCode(""); + setWhatsAppMessage(null); + }, 1500); + } else { + setWhatsAppMessage({ type: "error", text: "Invalid or expired code. Please try again." }); + } + setWhatsAppLinking(false); + }, 1500); + }; + + const handleWhatsAppDisconnect = () => { + setWhatsAppConnected(false); + setWhatsAppPhone(""); + }; + const primaryEmail = user.emailAddresses.find( (email) => email.id === user.primaryEmailAddressId ); @@ -311,41 +347,74 @@ export default function AccountPage() {

Connected Accounts

-
- {user.externalAccounts.length > 0 ? ( -
- {user.externalAccounts.map((account) => ( -
-
- {account.provider === "google" && ( - - - - - - - )} - {account.provider === "github" && ( - - - - )} -
- {account.provider} - {account.emailAddress} -
-
- - Connected - +
+ {user.externalAccounts.map((account) => ( +
+
+ {account.provider === "google" && ( + + + + + + + )} + {account.provider === "github" && ( + + + + )} +
+ {account.provider} + {account.emailAddress}
- ))} +
+ + Connected + +
+ ))} + + {/* WhatsApp Connection */} +
+
+ + + +
+ WhatsApp + {whatsAppConnected && ( + {whatsAppPhone} + )} +
- ) : ( -

No connected accounts

+ {whatsAppConnected ? ( +
+ + Connected + + +
+ ) : ( + + )} +
+ + {user.externalAccounts.length === 0 && !whatsAppConnected && ( +

No connected accounts yet

)}
@@ -482,6 +551,117 @@ export default function AccountPage() {
)} + {/* WhatsApp Link Modal */} + {showWhatsAppModal && ( +
+
{ + setShowWhatsAppModal(false); + setWhatsAppCode(""); + setWhatsAppMessage(null); + }} + /> +
+
+
+ + + +
+
+

Connect WhatsApp

+

Link your WhatsApp number to Loglife

+
+
+ + {/* How it works steps */} +
+

How it works

+
+
+ 1 +

Open a chat with the Loglife WhatsApp bot

+
+
+ 2 +

Send link account to the bot

+
+
+ 3 +

You'll receive a 6-digit linking code (valid for 5 min)

+
+
+ 4 +

Enter the code below to link your account

+
+
+
+ + {whatsAppMessage && ( +
+ {whatsAppMessage.text} +
+ )} + +
+
+ + setWhatsAppCode(e.target.value.replace(/[^0-9]/g, "").slice(0, 6))} + className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600 font-mono tracking-[0.3em] text-center text-lg" + placeholder="------" + maxLength={6} + autoFocus + /> +

Enter the 6-digit code from WhatsApp

+
+ +
+ + +
+
+
+
+ )} + {/* Password Change Modal */} {showPasswordModal && (
From a0572d4e9f0e2272f984574f2d1fd4b25d3d31ca Mon Sep 17 00:00:00 2001 From: hafiz-ahtasham-ali Date: Fri, 20 Feb 2026 16:24:59 +0500 Subject: [PATCH 02/32] Load WhatsApp session data and store session ID in Clerk user metadata --- website/app/account/page.tsx | 93 ++++----- website/app/api/sessions/route.ts | 66 +++++++ website/app/dashboard/page.tsx | 307 +++++++++++++++++++++++++----- 3 files changed, 377 insertions(+), 89 deletions(-) create mode 100644 website/app/api/sessions/route.ts diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index a20174cf..8eb02319 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -24,12 +24,13 @@ export default function AccountPage() { const [passwordLoading, setPasswordLoading] = useState(false); const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [showWhatsAppModal, setShowWhatsAppModal] = useState(false); - const [whatsAppCode, setWhatsAppCode] = useState(""); - const [whatsAppConnected, setWhatsAppConnected] = useState(false); + const [whatsAppSessionId, setWhatsAppSessionId] = useState(""); const [whatsAppLinking, setWhatsAppLinking] = useState(false); - const [whatsAppPhone, setWhatsAppPhone] = useState(""); const [whatsAppMessage, setWhatsAppMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const storedSessionId = (user?.unsafeMetadata as Record | undefined)?.whatsappSessionId || ""; + const whatsAppConnected = !!storedSessionId; + React.useEffect(() => { if (user) { setFirstName(user.firstName || ""); @@ -126,32 +127,43 @@ export default function AccountPage() { const handleWhatsAppLink = async (e: React.FormEvent) => { e.preventDefault(); - if (!whatsAppCode.trim()) return; + const sid = whatsAppSessionId.trim(); + if (!sid) return; + + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(sid)) { + setWhatsAppMessage({ type: "error", text: "Invalid session ID format. Expected a UUID like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }); + return; + } setWhatsAppLinking(true); setWhatsAppMessage(null); - // Static simulation — pretend to verify the code - setTimeout(() => { - if (whatsAppCode.trim().length >= 4 && whatsAppCode.trim() !== "000000") { - setWhatsAppConnected(true); - setWhatsAppPhone("+92 3XX XXXXXXX"); - setWhatsAppMessage({ type: "success", text: "WhatsApp account linked successfully!" }); - setTimeout(() => { - setShowWhatsAppModal(false); - setWhatsAppCode(""); - setWhatsAppMessage(null); - }, 1500); - } else { - setWhatsAppMessage({ type: "error", text: "Invalid or expired code. Please try again." }); - } + try { + await user!.update({ + unsafeMetadata: { ...user!.unsafeMetadata, whatsappSessionId: sid }, + }); + setWhatsAppMessage({ type: "success", text: "WhatsApp session linked successfully!" }); + setTimeout(() => { + setShowWhatsAppModal(false); + setWhatsAppSessionId(""); + setWhatsAppMessage(null); + }, 1500); + } catch { + setWhatsAppMessage({ type: "error", text: "Failed to save session ID. Please try again." }); + } finally { setWhatsAppLinking(false); - }, 1500); + } }; - const handleWhatsAppDisconnect = () => { - setWhatsAppConnected(false); - setWhatsAppPhone(""); + const handleWhatsAppDisconnect = async () => { + try { + await user!.update({ + unsafeMetadata: { ...user!.unsafeMetadata, whatsappSessionId: undefined }, + }); + } catch { + // silently fail + } }; const primaryEmail = user.emailAddresses.find( @@ -387,7 +399,7 @@ export default function AccountPage() {
WhatsApp {whatsAppConnected && ( - {whatsAppPhone} + {storedSessionId.slice(0, 8)}... )}
@@ -558,7 +570,7 @@ export default function AccountPage() { className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => { setShowWhatsAppModal(false); - setWhatsAppCode(""); + setWhatsAppSessionId(""); setWhatsAppMessage(null); }} /> @@ -571,7 +583,7 @@ export default function AccountPage() {

Connect WhatsApp

-

Link your WhatsApp number to Loglife

+

Link your WhatsApp session to Loglife

@@ -581,19 +593,15 @@ export default function AccountPage() {
1 -

Open a chat with the Loglife WhatsApp bot

+

Start a conversation with the Loglife WhatsApp bot

2 -

Send link account to the bot

+

Get your session ID from the bot or your admin

3 -

You'll receive a 6-digit linking code (valid for 5 min)

-
-
- 4 -

Enter the code below to link your account

+

Paste the session ID below to link your account

@@ -613,18 +621,17 @@ export default function AccountPage() {
setWhatsAppCode(e.target.value.replace(/[^0-9]/g, "").slice(0, 6))} - className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600 font-mono tracking-[0.3em] text-center text-lg" - placeholder="------" - maxLength={6} + value={whatsAppSessionId} + onChange={(e) => setWhatsAppSessionId(e.target.value.trim())} + className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600 font-mono" + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autoFocus /> -

Enter the 6-digit code from WhatsApp

+

Paste the session UUID from your WhatsApp bot

@@ -632,7 +639,7 @@ export default function AccountPage() { type="button" onClick={() => { setShowWhatsAppModal(false); - setWhatsAppCode(""); + setWhatsAppSessionId(""); setWhatsAppMessage(null); }} className="flex-1 px-4 py-2 rounded-lg text-sm font-medium text-slate-300 bg-slate-800 hover:bg-slate-700 transition-all cursor-pointer" @@ -641,7 +648,7 @@ export default function AccountPage() {
diff --git a/website/app/api/sessions/route.ts b/website/app/api/sessions/route.ts new file mode 100644 index 00000000..3dc75e6d --- /dev/null +++ b/website/app/api/sessions/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readFile } from "fs/promises"; +import { join } from "path"; + +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("sessionId"); + const key = req.nextUrl.searchParams.get("key"); + + if (!sessionId && !key) { + return NextResponse.json({ error: "Provide ?sessionId= or ?key=" }, { status: 400 }); + } + + try { + // const filePath = join(process.cwd(), "..", "sessions.json"); + const filePath = join("/home/ali/.openclaw/agents/main/sessions", "sessions.json"); + const raw = await readFile(filePath, "utf-8"); + const sessions: Record> = JSON.parse(raw); + + let session: Record | undefined; + let matchedKey = key || ""; + + if (key) { + session = sessions[key]; + } else if (sessionId) { + for (const [k, v] of Object.entries(sessions)) { + if (v.sessionId === sessionId) { + session = v; + matchedKey = k; + break; + } + } + } + + if (!session) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + const origin = session.origin as Record | undefined; + const delivery = session.deliveryContext as Record | undefined; + + return NextResponse.json({ + sessionKey: matchedKey, + sessionId: session.sessionId, + updatedAt: session.updatedAt, + abortedLastRun: session.abortedLastRun, + chatType: session.chatType, + lastChannel: session.lastChannel, + origin: { + label: origin?.label, + from: origin?.from, + to: origin?.to, + }, + deliveryContext: { + channel: delivery?.channel, + to: delivery?.to, + }, + compactionCount: session.compactionCount, + inputTokens: session.inputTokens, + outputTokens: session.outputTokens, + totalTokens: session.totalTokens, + model: session.model, + }); + } catch { + return NextResponse.json({ error: "Failed to read sessions" }, { status: 500 }); + } +} diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index d470a015..63aa607b 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -5,13 +5,73 @@ import Image from "next/image"; import Link from "next/link"; import { useState, useRef, useEffect } from "react"; +interface WhatsAppSession { + sessionKey: string; + sessionId: string; + updatedAt: number; + abortedLastRun: boolean; + chatType: string; + lastChannel: string; + origin: { label: string; from: string; to: string }; + deliveryContext: { channel: string; to: string }; + compactionCount: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + model: string; +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + if (seconds < 0) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function formatTokens(count: number): string { + if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; + return count.toString(); +} + export default function DashboardPage() { const { user, isLoaded } = useUser(); const { signOut } = useClerk(); const router = useRouter(); const [menuOpen, setMenuOpen] = useState(false); + const [session, setSession] = useState(null); + const [sessionLoading, setSessionLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const menuRef = useRef(null); + const whatsappSessionId = (user?.unsafeMetadata as Record | undefined)?.whatsappSessionId || ""; + + const fetchSession = (isRefresh = false) => { + if (!whatsappSessionId) { + setSession(null); + setSessionLoading(false); + return; + } + + if (isRefresh) setRefreshing(true); + else setSessionLoading(true); + + fetch(`/api/sessions?sessionId=${encodeURIComponent(whatsappSessionId)}`) + .then((res) => res.json()) + .then((data) => { if (!data.error) setSession(data); else setSession(null); }) + .catch(() => { setSession(null); }) + .finally(() => { setSessionLoading(false); setRefreshing(false); }); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { fetchSession(); }, [whatsappSessionId]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { @@ -45,11 +105,23 @@ export default function DashboardPage() {
{/* Header */}
-
-

Dashboard

-

- Welcome back, {user.firstName || user.emailAddresses[0]?.emailAddress} -

+
+
+

Dashboard

+

+ Welcome back, {user.firstName || user.emailAddresses[0]?.emailAddress} +

+
+
{/* User Menu */} @@ -108,89 +180,202 @@ export default function DashboardPage() {
+ {sessionLoading ? ( +
+
+
+ ) : !session ? ( +
+
+ + + +
+

No WhatsApp session found

+

Connect your WhatsApp account in Account Settings to see session data here.

+
+ ) : ( + <> {/* Stats Grid */}
-

Journal Entries

-

0

+

Active Sessions

+

1

-
- - +
+ +
-

No entries yet

+

WhatsApp connected

-

Current Streak

-

0 days

+

Total Tokens

+

{formatTokens(session.totalTokens)}

-
- - - +
+ +
-

Start journaling to build your streak

+
+ {formatTokens(session.inputTokens)} in + / + {formatTokens(session.outputTokens)} out +
-

Highlights

-

0

+

Model

+

{session.model}

-
- - +
+ +
-

This week

+

OpenAI

-

Days Active

-

--

+

Status

+

+ {session.abortedLastRun ? ( + Error + ) : ( + Active + )} +

-
- - - +
+ {session.abortedLastRun ? ( + + + + ) : ( + + + + )}
-

No data yet

+

Last active {formatRelativeTime(session.updatedAt)}

{/* Main Content */}
- {/* Journal Section */} + {/* WhatsApp Session Detail */}
-

Your Journal

- +
+ + + +

WhatsApp Session

+
+ + {session.abortedLastRun ? "Error" : "Active"} +
-
-
-
- - +
+ {/* User & Channel */} +
+
+ +
-

Start your first journal entry

-

Send a voice note or text to begin capturing your day

+
+

{session.origin.label}

+

WhatsApp Direct Message

+
+
+

{formatRelativeTime(session.updatedAt)}

+

last active

+
+
+ + {/* Session Details Grid */} +
+
+

Session ID

+

{session.sessionId}

+
+
+

Channel

+
+ +

{session.lastChannel}

+
+
+
+

Chat Type

+

{session.chatType}

+
+
+

Compactions

+

{session.compactionCount}

+
+
+ + {/* Token Usage Bar */} +
+
+

Token Usage

+

{formatTokens(session.totalTokens)} total

+
+
+
+
+
+
+
+ + Input: {formatTokens(session.inputTokens)} +
+
+ + Output: {formatTokens(session.outputTokens)} +
+
+ of 128k context +
+
+ + {/* Origin Details */} +
+

Delivery Context

+
+
+

From

+

{session.origin.from}

+
+
+

To

+

{session.deliveryContext.to}

+
+
+

Channel

+

{session.deliveryContext.channel}

+
+
+

Model

+

{session.model}

+
+
@@ -257,12 +442,42 @@ export default function DashboardPage() {

Recent Activity

-
-
-

No recent activity

+
+
+
+ + + +
+
+

WhatsApp session active with {session.origin.label}

+

{formatRelativeTime(session.updatedAt)} · {formatTokens(session.totalTokens)} tokens used · {session.model}

+
+ + Active + +
+ +
+ +
+
+ + + +
+
+

WhatsApp account {session.origin.label} linked

+

Account connected via linking code

+
+ + Connected +
+ + )}
); From 3c413dabf74baa058a244c7c65c5f10a733672bb Mon Sep 17 00:00:00 2001 From: hafiz-ahtasham-ali Date: Fri, 20 Feb 2026 19:13:29 +0500 Subject: [PATCH 03/32] Change WhatsApp widget default message from "help" to "START" --- website/app/components/WhatsAppWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/app/components/WhatsAppWidget.tsx b/website/app/components/WhatsAppWidget.tsx index 62ad5562..6794ab12 100644 --- a/website/app/components/WhatsAppWidget.tsx +++ b/website/app/components/WhatsAppWidget.tsx @@ -68,7 +68,7 @@ export default function WhatsAppWidget() { const [isPanelEntered, setIsPanelEntered] = useState(false); const closeTimeoutRef = useRef | null>(null); const number = "17155157761"; - const message = "help"; + const message = "START"; const link = `https://wa.me/${number}?text=${encodeURIComponent(message)}`; useEffect(() => { From 009c4af1a0a66bd4d096db4f355eb7d58a325075 Mon Sep 17 00:00:00 2001 From: hafiz-ahtasham-ali Date: Sat, 21 Feb 2026 06:38:50 +0500 Subject: [PATCH 04/32] fix: add null safety to dashboard session data to prevent client-side crash on missing fields --- website/app/api/sessions/route.ts | 30 +++++------ website/app/dashboard/page.tsx | 86 ++++++++++++++++--------------- 2 files changed, 59 insertions(+), 57 deletions(-) diff --git a/website/app/api/sessions/route.ts b/website/app/api/sessions/route.ts index 3dc75e6d..d67f36fa 100644 --- a/website/app/api/sessions/route.ts +++ b/website/app/api/sessions/route.ts @@ -40,25 +40,25 @@ export async function GET(req: NextRequest) { return NextResponse.json({ sessionKey: matchedKey, - sessionId: session.sessionId, - updatedAt: session.updatedAt, - abortedLastRun: session.abortedLastRun, - chatType: session.chatType, - lastChannel: session.lastChannel, + sessionId: session.sessionId ?? "", + updatedAt: session.updatedAt ?? 0, + abortedLastRun: session.abortedLastRun ?? false, + chatType: session.chatType ?? origin?.chatType ?? "unknown", + lastChannel: session.lastChannel ?? delivery?.channel ?? "unknown", origin: { - label: origin?.label, - from: origin?.from, - to: origin?.to, + label: origin?.label ?? "Unknown", + from: origin?.from ?? "", + to: origin?.to ?? "", }, deliveryContext: { - channel: delivery?.channel, - to: delivery?.to, + channel: delivery?.channel ?? "unknown", + to: delivery?.to ?? "", }, - compactionCount: session.compactionCount, - inputTokens: session.inputTokens, - outputTokens: session.outputTokens, - totalTokens: session.totalTokens, - model: session.model, + compactionCount: session.compactionCount ?? 0, + inputTokens: session.inputTokens ?? 0, + outputTokens: session.outputTokens ?? 0, + totalTokens: session.totalTokens ?? 0, + model: session.model ?? "unknown", }); } catch { return NextResponse.json({ error: "Failed to read sessions" }, { status: 500 }); diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index 63aa607b..a2d87fa1 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -6,22 +6,23 @@ import Link from "next/link"; import { useState, useRef, useEffect } from "react"; interface WhatsAppSession { - sessionKey: string; - sessionId: string; - updatedAt: number; - abortedLastRun: boolean; - chatType: string; - lastChannel: string; - origin: { label: string; from: string; to: string }; - deliveryContext: { channel: string; to: string }; - compactionCount: number; - inputTokens: number; - outputTokens: number; - totalTokens: number; - model: string; + sessionKey?: string; + sessionId?: string; + updatedAt?: number; + abortedLastRun?: boolean; + chatType?: string; + lastChannel?: string; + origin?: { label?: string; from?: string; to?: string }; + deliveryContext?: { channel?: string; to?: string }; + compactionCount?: number; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + model?: string; } -function formatRelativeTime(timestamp: number): string { +function formatRelativeTime(timestamp: number | undefined | null): string { + if (timestamp == null || timestamp === 0) return "never"; const now = Date.now(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); @@ -35,7 +36,8 @@ function formatRelativeTime(timestamp: number): string { return `${days}d ago`; } -function formatTokens(count: number): string { +function formatTokens(count: number | undefined | null): string { + if (count == null) return "0"; if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; return count.toString(); } @@ -217,7 +219,7 @@ export default function DashboardPage() {

Total Tokens

-

{formatTokens(session.totalTokens)}

+

{formatTokens(session?.totalTokens)}

@@ -226,9 +228,9 @@ export default function DashboardPage() {
- {formatTokens(session.inputTokens)} in + {formatTokens(session?.inputTokens)} in / - {formatTokens(session.outputTokens)} out + {formatTokens(session?.outputTokens)} out
@@ -236,7 +238,7 @@ export default function DashboardPage() {

Model

-

{session.model}

+

{session?.model || "N/A"}

@@ -252,15 +254,15 @@ export default function DashboardPage() {

Status

- {session.abortedLastRun ? ( + {session?.abortedLastRun ? ( Error ) : ( Active )}

-
- {session.abortedLastRun ? ( +
+ {session?.abortedLastRun ? ( @@ -271,7 +273,7 @@ export default function DashboardPage() { )}
-

Last active {formatRelativeTime(session.updatedAt)}

+

Last active {formatRelativeTime(session?.updatedAt)}

@@ -286,8 +288,8 @@ export default function DashboardPage() {

WhatsApp Session

- - {session.abortedLastRun ? "Error" : "Active"} + + {session?.abortedLastRun ? "Error" : "Active"}
@@ -299,11 +301,11 @@ export default function DashboardPage() {
-

{session.origin.label}

+

{session?.origin?.label || "Unknown"}

WhatsApp Direct Message

-

{formatRelativeTime(session.updatedAt)}

+

{formatRelativeTime(session?.updatedAt)}

last active

@@ -312,22 +314,22 @@ export default function DashboardPage() {

Session ID

-

{session.sessionId}

+

{session?.sessionId || "N/A"}

Channel

-

{session.lastChannel}

+

{session?.lastChannel || "N/A"}

Chat Type

-

{session.chatType}

+

{session?.chatType || "N/A"}

Compactions

-

{session.compactionCount}

+

{session?.compactionCount ?? 0}

@@ -335,20 +337,20 @@ export default function DashboardPage() {

Token Usage

-

{formatTokens(session.totalTokens)} total

+

{formatTokens(session?.totalTokens)} total

-
+
- Input: {formatTokens(session.inputTokens)} + Input: {formatTokens(session?.inputTokens)}
- Output: {formatTokens(session.outputTokens)} + Output: {formatTokens(session?.outputTokens)}
of 128k context @@ -361,19 +363,19 @@ export default function DashboardPage() {

From

-

{session.origin.from}

+

{session?.origin?.from || "N/A"}

To

-

{session.deliveryContext.to}

+

{session?.deliveryContext?.to || "N/A"}

Channel

-

{session.deliveryContext.channel}

+

{session?.deliveryContext?.channel || "N/A"}

Model

-

{session.model}

+

{session?.model || "N/A"}

@@ -450,8 +452,8 @@ export default function DashboardPage() {
-

WhatsApp session active with {session.origin.label}

-

{formatRelativeTime(session.updatedAt)} · {formatTokens(session.totalTokens)} tokens used · {session.model}

+

WhatsApp session active with {session?.origin?.label || "Unknown"}

+

{formatRelativeTime(session?.updatedAt)} · {formatTokens(session?.totalTokens)} tokens used · {session?.model || "N/A"}

Active @@ -467,7 +469,7 @@ export default function DashboardPage() {
-

WhatsApp account {session.origin.label} linked

+

WhatsApp account {session?.origin?.label || "Unknown"} linked

Account connected via linking code

From bbbc697c10d47ccf0a8fc16a72d6b6d8acb6a7ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 12:38:42 -0800 Subject: [PATCH 05/32] remove github and reorder auth in signup/in pages --- website/app/login/page.tsx | 57 +++++++++++++++---------------------- website/app/signup/page.tsx | 57 +++++++++++++++---------------------- 2 files changed, 46 insertions(+), 68 deletions(-) diff --git a/website/app/login/page.tsx b/website/app/login/page.tsx index ebd39c5b..376c5150 100644 --- a/website/app/login/page.tsx +++ b/website/app/login/page.tsx @@ -38,11 +38,11 @@ export default function LoginPage() { } }; - const handleOAuthSignIn = async (strategy: "oauth_google" | "oauth_github") => { + const handleGoogleSignIn = async () => { if (!isLoaded) return; try { await signIn.authenticateWithRedirect({ - strategy, + strategy: "oauth_google", redirectUrl: "/sso-callback", redirectUrlComplete: "/dashboard", }); @@ -82,6 +82,27 @@ export default function LoginPage() {
)} + + +
+
+
+ Or sign in with email +
+
+
+
{/* Footer */} diff --git a/website/app/signup/page.tsx b/website/app/signup/page.tsx index 7a0ec7d3..59bf7f90 100644 --- a/website/app/signup/page.tsx +++ b/website/app/signup/page.tsx @@ -64,11 +64,11 @@ export default function SignupPage() { } }; - const handleOAuthSignUp = async (strategy: "oauth_google" | "oauth_github") => { + const handleGoogleSignUp = async () => { if (!isLoaded) return; try { await signUp.authenticateWithRedirect({ - strategy, + strategy: "oauth_google", redirectUrl: "/sso-callback", redirectUrlComplete: "/dashboard", }); @@ -152,6 +152,27 @@ export default function SignupPage() {
)} + + +
+
+
+ Or sign up with email +
+
+
+
@@ -227,38 +248,6 @@ export default function SignupPage() { {loading ? "Creating account..." : "Create Account"} - -
-
-
- Or sign up with -
-
- -
- - -
-
{/* Footer */} From dc53a6013a4ddcdc817a8dcacd561f63f57783e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 12:41:06 -0800 Subject: [PATCH 06/32] put free early access button to sign-up page --- website/app/features/page.tsx | 10 +++++----- website/app/hero/hero.tsx | 13 +++++++------ website/app/pricing/page.tsx | 16 ++++++++-------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/website/app/features/page.tsx b/website/app/features/page.tsx index 07aa5136..a07ca02f 100644 --- a/website/app/features/page.tsx +++ b/website/app/features/page.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; import Image from "next/image"; -import { useWhatsAppWidget } from "../contexts/WhatsAppWidgetContext"; +import Link from "next/link"; // Channel icons as SVG components const ChannelIcons = { @@ -194,7 +194,7 @@ function ChannelBadge({ status }: { status: string }) { } export default function FeaturesPage() { - const { openWidget } = useWhatsAppWidget(); + return (
@@ -413,15 +413,15 @@ export default function FeaturesPage() {

Ready to try it?

No card needed.

- +
diff --git a/website/app/hero/hero.tsx b/website/app/hero/hero.tsx index bb063ec4..6dd916ab 100644 --- a/website/app/hero/hero.tsx +++ b/website/app/hero/hero.tsx @@ -1,5 +1,6 @@ "use client"; import React, { useState, useEffect, useRef } from "react"; +import Link from "next/link"; import { useWhatsAppWidget } from "../contexts/WhatsAppWidgetContext"; function useInView(threshold = 0.15) { @@ -48,15 +49,15 @@ function Hero() {

- +
- +

No card needed.

diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index aed524fd..4592011a 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; -import { useWhatsAppWidget } from "../contexts/WhatsAppWidgetContext"; +import Link from "next/link"; function CheckIcon({ className = "w-5 h-5" }: { className?: string }) { return ( @@ -198,7 +198,7 @@ function AnimatedComparison() { } export default function PricingPage() { - const { openWidget } = useWhatsAppWidget(); + return (
@@ -301,15 +301,15 @@ export default function PricingPage() { ))}
- +

No card needed.

@@ -452,15 +452,15 @@ export default function PricingPage() {

Ready to try it?

No card needed.

- +
From 2f5d136e0b475212cd746be4da6f883d47ab7254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 14:21:55 -0800 Subject: [PATCH 07/32] make loglife plugin --- .github/workflows/deploy.yml | 68 +++------------ plugin/index.ts | 138 ++++++++++++++++++++++++++++++ plugin/openclaw.plugin.json | 21 +++++ plugin/package.json | 9 ++ website/app/api/sessions/route.ts | 71 +++++---------- 5 files changed, 199 insertions(+), 108 deletions(-) create mode 100644 plugin/index.ts create mode 100644 plugin/openclaw.plugin.json create mode 100644 plugin/package.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 15d0d4d3..432868c5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,16 +1,14 @@ -name: Deploy Website +name: Deploy Plugin on: - pull_request: + push: branches: - main - types: - - opened - - synchronize - - reopened + paths: + - 'plugin/**' concurrency: - group: deploy-preview-${{ github.event.pull_request.number }} + group: deploy-production cancel-in-progress: true jobs: @@ -18,63 +16,19 @@ jobs: runs-on: ubuntu-latest steps: - - name: Deploy to server + - name: Update plugin on server uses: appleboy/ssh-action@v1 - env: - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} - envs: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY script: | set -e - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" - - DEPLOY_DIR="$HOME/loglife-preview" - APP_NAME=loglife-preview - PORT=3001 - BRANCH=${{ github.head_ref }} - - if [ ! -d "$DEPLOY_DIR" ]; then - git clone https://github.com/${{ github.repository }}.git "$DEPLOY_DIR" - fi - - cd "$DEPLOY_DIR" + cd ~/loglife git fetch origin - git checkout -- . - git clean -fd - git checkout "$BRANCH" - git reset --hard "origin/$BRANCH" - - cd website - pnpm install --frozen-lockfile - pnpm run build - - NODE_BIN=$(which node) - NEXT_BIN="$DEPLOY_DIR/website/node_modules/.bin/next" - - mkdir -p "$HOME/.config/systemd/user" - printf '%s\n' \ - "[Unit]" \ - "Description=LogLife Preview" \ - "After=network.target" \ - "" \ - "[Service]" \ - "Type=simple" \ - "WorkingDirectory=$DEPLOY_DIR/website" \ - "Environment=PORT=$PORT" \ - "Environment=NODE_ENV=production" \ - "Environment=NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" \ - "ExecStart=$NODE_BIN $NEXT_BIN start --port $PORT" \ - "Restart=on-failure" \ - "" \ - "[Install]" \ - "WantedBy=default.target" \ - > "$HOME/.config/systemd/user/$APP_NAME.service" + git checkout main + git reset --hard origin/main - systemctl --user daemon-reload - systemctl --user enable "$APP_NAME" - systemctl --user restart "$APP_NAME" + # Uncomment to auto-restart the gateway after deploying plugin changes: + # openclaw gateway restart diff --git a/plugin/index.ts b/plugin/index.ts new file mode 100644 index 00000000..5d91c255 --- /dev/null +++ b/plugin/index.ts @@ -0,0 +1,138 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { timingSafeEqual } from "node:crypto"; +import { URL } from "node:url"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +type LogLifeConfig = { + apiKey: string; + agentId?: string; +}; + +function verifyApiKey(req: IncomingMessage, expectedKey: string): boolean { + const auth = req.headers.authorization ?? ""; + const prefix = "Bearer "; + if (!auth.startsWith(prefix)) return false; + const token = auth.slice(prefix.length); + if (token.length !== expectedKey.length) return false; + try { + return timingSafeEqual(Buffer.from(token), Buffer.from(expectedKey)); + } catch { + return false; + } +} + +function jsonResponse(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +const plugin = { + id: "loglife", + name: "LogLife", + description: "Exposes session data over HTTP for the LogLife dashboard", + configSchema: { + type: "object" as const, + additionalProperties: false, + required: ["apiKey"], + properties: { + apiKey: { type: "string" as const }, + agentId: { type: "string" as const, default: "main" }, + }, + }, + + register(api: OpenClawPluginApi) { + const cfg = (api.pluginConfig ?? {}) as LogLifeConfig; + const apiKey = cfg.apiKey; + const agentId = cfg.agentId ?? "main"; + + if (!apiKey) { + api.logger.warn("LogLife plugin: apiKey not configured — HTTP route will reject all requests"); + } + + const stateDir = process.env.OPENCLAW_STATE_DIR + ?? join(process.env.HOME ?? "/root", ".openclaw"); + const sessionsPath = join(stateDir, "agents", agentId, "sessions", "sessions.json"); + + api.registerHttpRoute({ + path: "/loglife/sessions", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "GET") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const sessionId = url.searchParams.get("sessionId"); + const key = url.searchParams.get("key"); + + if (!sessionId && !key) { + jsonResponse(res, 400, { error: "Provide ?sessionId= or ?key=" }); + return; + } + + try { + const raw = await readFile(sessionsPath, "utf-8"); + const sessions: Record> = JSON.parse(raw); + + let session: Record | undefined; + let matchedKey = key || ""; + + if (key) { + session = sessions[key]; + } else if (sessionId) { + for (const [k, v] of Object.entries(sessions)) { + if (v.sessionId === sessionId) { + session = v; + matchedKey = k; + break; + } + } + } + + if (!session) { + jsonResponse(res, 404, { error: "Session not found" }); + return; + } + + const origin = session.origin as Record | undefined; + const delivery = session.deliveryContext as Record | undefined; + + jsonResponse(res, 200, { + sessionKey: matchedKey, + sessionId: session.sessionId ?? "", + updatedAt: session.updatedAt ?? 0, + abortedLastRun: session.abortedLastRun ?? false, + chatType: session.chatType ?? origin?.chatType ?? "unknown", + lastChannel: session.lastChannel ?? delivery?.channel ?? "unknown", + origin: { + label: origin?.label ?? "Unknown", + from: origin?.from ?? "", + to: origin?.to ?? "", + }, + deliveryContext: { + channel: delivery?.channel ?? "unknown", + to: delivery?.to ?? "", + }, + compactionCount: session.compactionCount ?? 0, + inputTokens: session.inputTokens ?? 0, + outputTokens: session.outputTokens ?? 0, + totalTokens: session.totalTokens ?? 0, + model: session.model ?? "unknown", + }); + } catch { + jsonResponse(res, 500, { error: "Failed to read sessions" }); + } + }, + }); + }, +}; + +export default plugin; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json new file mode 100644 index 00000000..6c92c229 --- /dev/null +++ b/plugin/openclaw.plugin.json @@ -0,0 +1,21 @@ +{ + "id": "loglife", + "name": "LogLife", + "description": "Exposes session data over HTTP for the LogLife dashboard", + "configSchema": { + "type": "object", + "additionalProperties": false, + "required": ["apiKey"], + "properties": { + "apiKey": { + "type": "string", + "description": "Shared secret for authenticating requests from the LogLife dashboard" + }, + "agentId": { + "type": "string", + "default": "main", + "description": "Which agent's sessions to serve (defaults to 'main')" + } + } + } +} diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 00000000..6bb051c2 --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,9 @@ +{ + "name": "loglife-plugin", + "version": "0.1.0", + "description": "LogLife dashboard API plugin for OpenClaw", + "type": "module", + "openclaw": { + "extensions": ["."] + } +} diff --git a/website/app/api/sessions/route.ts b/website/app/api/sessions/route.ts index d67f36fa..8fdba17c 100644 --- a/website/app/api/sessions/route.ts +++ b/website/app/api/sessions/route.ts @@ -1,8 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; -import { readFile } from "fs/promises"; -import { join } from "path"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; export async function GET(req: NextRequest) { + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + const sessionId = req.nextUrl.searchParams.get("sessionId"); const key = req.nextUrl.searchParams.get("key"); @@ -10,57 +18,18 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Provide ?sessionId= or ?key=" }, { status: 400 }); } - try { - // const filePath = join(process.cwd(), "..", "sessions.json"); - const filePath = join("/home/ali/.openclaw/agents/main/sessions", "sessions.json"); - const raw = await readFile(filePath, "utf-8"); - const sessions: Record> = JSON.parse(raw); - - let session: Record | undefined; - let matchedKey = key || ""; + const params = new URLSearchParams(); + if (sessionId) params.set("sessionId", sessionId); + if (key) params.set("key", key); - if (key) { - session = sessions[key]; - } else if (sessionId) { - for (const [k, v] of Object.entries(sessions)) { - if (v.sessionId === sessionId) { - session = v; - matchedKey = k; - break; - } - } - } - - if (!session) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - const origin = session.origin as Record | undefined; - const delivery = session.deliveryContext as Record | undefined; - - return NextResponse.json({ - sessionKey: matchedKey, - sessionId: session.sessionId ?? "", - updatedAt: session.updatedAt ?? 0, - abortedLastRun: session.abortedLastRun ?? false, - chatType: session.chatType ?? origin?.chatType ?? "unknown", - lastChannel: session.lastChannel ?? delivery?.channel ?? "unknown", - origin: { - label: origin?.label ?? "Unknown", - from: origin?.from ?? "", - to: origin?.to ?? "", - }, - deliveryContext: { - channel: delivery?.channel ?? "unknown", - to: delivery?.to ?? "", - }, - compactionCount: session.compactionCount ?? 0, - inputTokens: session.inputTokens ?? 0, - outputTokens: session.outputTokens ?? 0, - totalTokens: session.totalTokens ?? 0, - model: session.model ?? "unknown", + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/sessions?${params}`, { + headers: { Authorization: `Bearer ${OPENCLAW_API_KEY}` }, }); + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); } catch { - return NextResponse.json({ error: "Failed to read sessions" }, { status: 500 }); + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); } } From 13ce90f32c320faf9ce16f668f58db75385c58e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 15:35:13 -0800 Subject: [PATCH 08/32] add sessions plug-in instructions --- plugin/README.md | 55 +++++++++++++++++++++++++++++++++++++ plugin/openclaw-config.json | 12 ++++++++ 2 files changed, 67 insertions(+) create mode 100644 plugin/README.md create mode 100644 plugin/openclaw-config.json diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 00000000..779b8056 --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,55 @@ +# LogLife Plugin for OpenClaw + +Exposes session data over HTTP so the LogLife dashboard (hosted on Vercel) can display it. + +## Install + +```bash +# From the loglife repo root: +openclaw plugins install ./plugin --link +``` + +This registers the plugin and adds the path to your OpenClaw config. No files are copied — the plugin loads directly from the repo. + +## Configure + +1. Add the config include to your `openclaw.json`: + +```json +{ + "$include": ["~/loglife/plugin/openclaw-config.json"] +} +``` + +2. Set your API key (one-time, stays out of git): + +```bash +openclaw config set plugins.entries.loglife.config.apiKey "$(openssl rand -hex 32)" +``` + +3. Add the same key to your Vercel project settings as `OPENCLAW_API_KEY`, along with `OPENCLAW_API_URL` (your server's address, e.g. `https://your-server.com:18789`). + +4. Restart the gateway: + +```bash +openclaw gateway restart +``` + +## CI/CD + +Two independent deploy paths: + +- **Website** — Deploys to Vercel automatically on every push to `main`. No server involvement. +- **Plugin** — GitHub Actions triggers on pushes to `main` that change `plugin/**`. The workflow SSHes into the server and runs `git pull`. Restart the gateway manually via `/restart` in WhatsApp or `openclaw gateway restart`. + +## Development + +Run OpenClaw locally for development and testing. The server is production only. + +```bash +# Local setup: +openclaw plugins install ./plugin --link +cd website && pnpm dev +``` + +Edit `plugin/index.ts`, restart your local gateway, and test. Push to `main` when ready. diff --git a/plugin/openclaw-config.json b/plugin/openclaw-config.json new file mode 100644 index 00000000..1ae333af --- /dev/null +++ b/plugin/openclaw-config.json @@ -0,0 +1,12 @@ +{ + "plugins": { + "entries": { + "loglife": { + "enabled": true, + "config": { + "agentId": "main" + } + } + } + } +} From 3cebe1e509f54819409aa82028d507ae41073ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 16:16:38 -0800 Subject: [PATCH 09/32] update docs with full setup guide and fix plugin config Rewrite README and plugin/README with end-to-end setup instructions (OpenClaw install, plugin linking, API key, website .env). Fix OpenClaw GitHub URL. Make apiKey optional in plugin schema so install succeeds before configuration. Rename plugin package to match manifest ID. Co-authored-by: Cursor --- README.md | 108 ++++++++++++++++++++++++--------- plugin/README.md | 117 ++++++++++++++++++++++++++++-------- plugin/index.ts | 1 - plugin/openclaw.plugin.json | 1 - plugin/package.json | 2 +- 5 files changed, 173 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 4cbdbd24..b9914571 100644 --- a/README.md +++ b/README.md @@ -39,33 +39,87 @@ It combines a minimalist interface with powerful AI processing to help you **Cap ## 🏁 Getting Started ### Prerequisites -* Node.js 24+ -* pnpm - -### Running the Website - -1. **Clone the repository:** - ```bash - git clone https://github.com/jmoraispk/loglife.git - cd loglife/website - ``` - -2. **Install dependencies:** - ```bash - pnpm install - ``` - -3. **Run the development server:** - ```bash - pnpm dev - ``` - *The site will be available at `http://localhost:3000`.* - -4. **Build for production:** - ```bash - pnpm build - pnpm start - ``` + +- Node.js 24+ +- pnpm 10+ +- [OpenClaw](https://github.com/openclaw/openclaw) (for the dashboard) + +### Full development setup + +#### 1. Clone the repos + +```bash +git clone https://github.com/jmoraispk/loglife.git +git clone https://github.com/openclaw/openclaw.git ~/openclaw +``` + +#### 2. Build OpenClaw and install the plugin + +```bash +cd ~/openclaw +pnpm install +pnpm build +./openclaw.mjs plugins install /path/to/loglife/plugin --link +./openclaw.mjs config set plugins.entries.loglife.config.apiKey "$(openssl rand -hex 32)" +``` + +#### 3. Start the OpenClaw gateway + +```bash +cd ~/openclaw +./openclaw.mjs gateway --allow-unconfigured +``` + +#### 4. Set up and run the website + +```bash +cd loglife/website +pnpm install +``` + +Copy `.env` and add your OpenClaw connection: + +``` +OPENCLAW_API_URL=http://localhost:18789 +OPENCLAW_API_KEY= +``` + +```bash +pnpm dev +``` + +The site will be available at `http://localhost:3000`. The dashboard connects to the OpenClaw gateway to display session data. + +### Website only (no dashboard) + +If you only need the marketing site without the dashboard: + +```bash +cd loglife/website +pnpm install +pnpm dev +``` + +### Production build + +```bash +cd loglife/website +pnpm build +pnpm start +``` + +### Architecture + +``` +loglife/ +├── website/ → Next.js app (Vercel) — marketing site + dashboard +├── plugin/ → OpenClaw plugin — serves session data over HTTP +├── docs/ → Mintlify documentation (docs.loglife.co) +├── multi-user/ → Multi-user infrastructure for OpenClaw +└── call_prompts/→ Voice call prompt templates +``` + +The website is hosted on Vercel. The plugin runs inside the OpenClaw gateway on your server. The dashboard proxies requests through Vercel to the plugin, keeping the server URL and API key private. See [`plugin/README.md`](plugin/README.md) for detailed setup and CI/CD instructions. --- diff --git a/plugin/README.md b/plugin/README.md index 779b8056..9185f324 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,55 +1,120 @@ # LogLife Plugin for OpenClaw -Exposes session data over HTTP so the LogLife dashboard (hosted on Vercel) can display it. +Exposes session data over HTTP so the LogLife dashboard (hosted on Vercel) can display it. The plugin runs inside the OpenClaw gateway process — no separate service needed. -## Install +## Setup from scratch + +### 1. Install OpenClaw + +```bash +git clone https://github.com/openclaw/openclaw.git ~/openclaw +cd ~/openclaw +pnpm install +pnpm build +``` + +### 2. Install the plugin ```bash -# From the loglife repo root: -openclaw plugins install ./plugin --link +cd ~/openclaw +./openclaw.mjs plugins install /path/to/loglife/plugin --link ``` -This registers the plugin and adds the path to your OpenClaw config. No files are copied — the plugin loads directly from the repo. +This registers the plugin in your OpenClaw config and loads it directly from the repo (no copy). Updates arrive via `git pull`. -## Configure +### 3. Set the API key -1. Add the config include to your `openclaw.json`: +Generate a key and store it in your OpenClaw config: -```json -{ - "$include": ["~/loglife/plugin/openclaw-config.json"] -} +```bash +cd ~/openclaw +./openclaw.mjs config set plugins.entries.loglife.config.apiKey "$(openssl rand -hex 32)" ``` -2. Set your API key (one-time, stays out of git): +Note the key — you'll need it for the website in step 5. + +To see the current key: ```bash -openclaw config set plugins.entries.loglife.config.apiKey "$(openssl rand -hex 32)" +cat ~/.openclaw/openclaw.json | grep apiKey ``` -3. Add the same key to your Vercel project settings as `OPENCLAW_API_KEY`, along with `OPENCLAW_API_URL` (your server's address, e.g. `https://your-server.com:18789`). +### 4. Start the gateway + +```bash +cd ~/openclaw + +# Foreground (development): +./openclaw.mjs gateway --allow-unconfigured + +# Or as a background service (production): +./openclaw.mjs gateway install +./openclaw.mjs gateway start +``` -4. Restart the gateway: +Verify the plugin loaded by testing the endpoint: ```bash -openclaw gateway restart +curl -H "Authorization: Bearer YOUR_API_KEY" \ + "http://localhost:18789/loglife/sessions?sessionId=test" ``` -## CI/CD +You should get a JSON response (either session data or `{"error":"Session not found"}`). -Two independent deploy paths: +### 5. Set up the website -- **Website** — Deploys to Vercel automatically on every push to `main`. No server involvement. -- **Plugin** — GitHub Actions triggers on pushes to `main` that change `plugin/**`. The workflow SSHes into the server and runs `git pull`. Restart the gateway manually via `/restart` in WhatsApp or `openclaw gateway restart`. +```bash +cd loglife/website +pnpm install +``` -## Development +Add the OpenClaw connection to your `.env`: -Run OpenClaw locally for development and testing. The server is production only. +``` +OPENCLAW_API_URL=http://localhost:18789 +OPENCLAW_API_KEY= +``` + +Start the dev server: ```bash -# Local setup: -openclaw plugins install ./plugin --link -cd website && pnpm dev +pnpm dev ``` -Edit `plugin/index.ts`, restart your local gateway, and test. Push to `main` when ready. +The dashboard at `http://localhost:3000/dashboard` will now fetch session data through the plugin. + +## Production deployment + +### Website (Vercel) + +The website deploys to Vercel automatically on push to `main`. Set these environment variables in your Vercel project settings: + +- `OPENCLAW_API_URL` — your server's public address (e.g. `https://your-server.com:18789`) +- `OPENCLAW_API_KEY` — the same key from step 3 + +### Plugin (server) + +GitHub Actions triggers on pushes to `main` that change `plugin/**`. The workflow SSHes into the server and runs `git pull`. Restart the gateway manually afterwards: + +- Send `/restart` from WhatsApp (or any connected channel) +- Or run `openclaw gateway restart` via SSH + +### Config reference + +The plugin accepts two config values in `openclaw.json` under `plugins.entries.loglife.config`: + +| Key | Required | Default | Description | +|---|---|---|---| +| `apiKey` | Yes | — | Shared secret for authenticating dashboard requests | +| `agentId` | No | `"main"` | Which agent's sessions to serve | + +See `openclaw-config.json` for a template. + +## Development workflow + +All development happens locally. The production server is for deployment only. + +1. Run OpenClaw gateway locally (step 4 above) +2. Run the website dev server (step 5 above) +3. Edit `plugin/index.ts`, restart the local gateway, and test +4. Push to `main` when ready — Vercel deploys the website, GitHub Actions deploys the plugin diff --git a/plugin/index.ts b/plugin/index.ts index 5d91c255..19204817 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -36,7 +36,6 @@ const plugin = { configSchema: { type: "object" as const, additionalProperties: false, - required: ["apiKey"], properties: { apiKey: { type: "string" as const }, agentId: { type: "string" as const, default: "main" }, diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 6c92c229..ad13a431 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -5,7 +5,6 @@ "configSchema": { "type": "object", "additionalProperties": false, - "required": ["apiKey"], "properties": { "apiKey": { "type": "string", diff --git a/plugin/package.json b/plugin/package.json index 6bb051c2..9c192b64 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,5 +1,5 @@ { - "name": "loglife-plugin", + "name": "loglife", "version": "0.1.0", "description": "LogLife dashboard API plugin for OpenClaw", "type": "module", From b2d7afd95afefacd4af985c4cb2fdba39812b256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 16:27:21 -0800 Subject: [PATCH 10/32] add self-hosting documentation and update index layout Included a new "self-hosting" page in the documentation and updated the index layout to feature both "Quickstart" and "Self-hosting" cards for improved user navigation. --- docs/docs.json | 1 + docs/index.mdx | 26 +++-- docs/self-hosting.mdx | 216 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 docs/self-hosting.mdx diff --git a/docs/docs.json b/docs/docs.json index 46b44cc4..c7ca281a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -18,6 +18,7 @@ "pages": [ "index", "quickstart", + "self-hosting", "development" ] }, diff --git a/docs/index.mdx b/docs/index.mdx index 15c23fb6..1d05898c 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -5,16 +5,24 @@ description: "Welcome to the new home for your documentation" ## Setting up -Get your documentation site up and running in minutes. +Get LogLife running locally in minutes. - - Follow our three step quickstart guide. - + + + Get the marketing site running in minutes. + + + Set up OpenClaw, the plugin, and the full dashboard. + + ## Make it yours diff --git a/docs/self-hosting.mdx b/docs/self-hosting.mdx new file mode 100644 index 00000000..218dea85 --- /dev/null +++ b/docs/self-hosting.mdx @@ -0,0 +1,216 @@ +--- +title: "Self-hosting" +description: "Set up the LogLife dashboard and OpenClaw plugin from scratch" +--- + +LogLife runs as two independent pieces: a **Next.js website** hosted on Vercel (marketing site + dashboard) and an **OpenClaw plugin** that serves session data from your server. The dashboard talks to the plugin through a secure API — your server address and key never reach the browser. + +## Architecture + +```mermaid +%%{init: {'theme': 'neutral'}}%% +flowchart LR + browser["Browser"] --> site["Next.js app"] + browser -->|"dashboard request"| proxy["API route\n/api/sessions"] + proxy -->|"Bearer token"| plugin["LogLife plugin\n/loglife/sessions"] + plugin --> sessions["sessions.json"] + gw["OpenClaw gateway\nport 18789"] --- plugin +``` + +The website is a static marketing site for most visitors. When a logged-in user opens the dashboard, the Next.js API route proxies requests to the OpenClaw plugin. The plugin reads `sessions.json` and returns session data. Authentication uses a shared Bearer token — the key lives in your server's OpenClaw config and in Vercel's environment variables, never in client-side code. + +## Prerequisites + + + **You need:** + - [Node.js](https://nodejs.org) 24 or higher + - [pnpm](https://pnpm.io) 10 or higher + - [Git](https://git-scm.com) + + +## Setup + + + + + +```bash +git clone https://github.com/jmoraispk/loglife.git +git clone https://github.com/openclaw/openclaw.git ~/openclaw +``` + + + + + +```bash +cd ~/openclaw +pnpm install +pnpm build +``` + +This compiles the OpenClaw gateway and CLI tools you'll use in the next steps. + + + + + +```bash +cd ~/openclaw +./openclaw.mjs plugins install /path/to/loglife/plugin --link +``` + +The `--link` flag means the plugin loads directly from your LogLife repo — no files are copied. When you `git pull` new changes, the plugin updates automatically. + + + + + +```bash +cd ~/openclaw +./openclaw.mjs config set plugins.entries.loglife.config.apiKey "$(openssl rand -hex 32)" +``` + +This creates a random 256-bit key and stores it in `~/.openclaw/openclaw.json`. You'll need this key for the website in step 6. + +To retrieve the key later: + +```bash +grep apiKey ~/.openclaw/openclaw.json +``` + + + + + + + +```bash Development (foreground) +cd ~/openclaw +./openclaw.mjs gateway --allow-unconfigured +``` + +```bash Production (background service) +cd ~/openclaw +./openclaw.mjs gateway install +./openclaw.mjs gateway start +``` + + + +Verify the plugin loaded by hitting the endpoint: + +```bash +curl -H "Authorization: Bearer YOUR_API_KEY" \ + "http://localhost:18789/loglife/sessions?sessionId=test" +``` + +You should get a JSON response — either session data or `{"error":"Session not found"}`. Both mean the plugin is working. + +If you already have an OpenClaw instance with sessions, try a real session ID to see actual data. + + + + + +```bash +cd loglife/website +pnpm install +``` + +Create a `.env` file (or edit the existing one) with your OpenClaw connection: + +```bash .env +OPENCLAW_API_URL=http://localhost:18789 +OPENCLAW_API_KEY= +``` + +Start the dev server: + +```bash +pnpm dev +``` + +Open `http://localhost:3000`. The dashboard will now fetch session data through the plugin. + + + + + +## Production deployment + +### Website (Vercel) + +The website deploys to Vercel automatically on every push to `main`. Set these environment variables in your [Vercel project settings](https://vercel.com/docs/environment-variables): + +| Variable | Value | +|---|---| +| `OPENCLAW_API_URL` | Your server's public address (e.g. `https://your-server.com:18789`) | +| `OPENCLAW_API_KEY` | The key from step 4 above | + + + Make sure your server's port 18789 is reachable from Vercel's servers. If you're behind a firewall, consider using a reverse proxy (Nginx, Caddy) or a tunnel (Tailscale Funnel, Cloudflare Tunnel). + + +### Plugin (server) + +A GitHub Actions workflow triggers on pushes to `main` that change files in `plugin/**`. The workflow SSHes into your server and runs `git pull` to update the plugin code. + +After the pull, restart the gateway to load the changes: + +- Send `/restart` from WhatsApp (or any connected channel) +- Or run `openclaw gateway restart` via SSH + +## Plugin configuration + +The plugin accepts two config values in `openclaw.json` under `plugins.entries.loglife.config`: + +| Key | Required | Default | Description | +|---|---|---|---| +| `apiKey` | Yes | — | Shared secret for authenticating dashboard requests | +| `agentId` | No | `"main"` | Which agent's sessions to serve | + +Set values with the OpenClaw CLI: + +```bash +cd ~/openclaw +./openclaw.mjs config set plugins.entries.loglife.config.apiKey "your-key" +./openclaw.mjs config set plugins.entries.loglife.config.agentId "main" +``` + +## Troubleshooting + + + + + Make sure the `name` field in `plugin/package.json` matches the `id` in `plugin/openclaw.plugin.json`. Both should be `"loglife"`. + + + + Check that the gateway is running and the `OPENCLAW_API_URL` in your `.env` is correct. Test the connection directly: + + ```bash + curl -H "Authorization: Bearer YOUR_KEY" \ + "http://localhost:18789/loglife/sessions?sessionId=test" + ``` + + + + Another gateway process may already be running. Kill it and try again: + + ```bash + pkill -f "openclaw gateway" && ./openclaw.mjs gateway --allow-unconfigured + ``` + + + + The API key in your `.env` (or Vercel env vars) doesn't match the key in `~/.openclaw/openclaw.json`. Regenerate it and update both sides: + + ```bash + cd ~/openclaw + ./openclaw.mjs config set plugins.entries.loglife.config.apiKey "$(openssl rand -hex 32)" + grep apiKey ~/.openclaw/openclaw.json + ``` + + + From a70321e5255f41bf33b50e16d28417e724b6bfd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 16:49:29 -0800 Subject: [PATCH 11/32] Add OpenClaw tricks documentation Introduced a new documentation page titled "OpenClaw tricks" that provides useful tips for managing OpenClaw instances, including instructions for password-protecting the web UI with Nginx. Updated the index to include the new section for improved navigation. --- docs/docs.json | 1 + docs/openclaw-tricks.mdx | 113 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 docs/openclaw-tricks.mdx diff --git a/docs/docs.json b/docs/docs.json index c7ca281a..ffc5be73 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -19,6 +19,7 @@ "index", "quickstart", "self-hosting", + "openclaw-tricks", "development" ] }, diff --git a/docs/openclaw-tricks.mdx b/docs/openclaw-tricks.mdx new file mode 100644 index 00000000..eb9f2b3c --- /dev/null +++ b/docs/openclaw-tricks.mdx @@ -0,0 +1,113 @@ +--- +title: "OpenClaw tricks" +sidebarTitle: "OpenClaw tricks" +description: "Useful tips for managing your OpenClaw instance" +--- + +## Password-protect the OpenClaw web UI with Nginx + +By default the OpenClaw gateway web UI (port 18789) has no authentication. You can put it behind an Nginx reverse proxy with HTTP Basic Auth so only authorized users can access it. + +### Prerequisites + +- A running OpenClaw gateway (see [Self-hosting](/self-hosting)) +- Nginx installed on the same server +- Root / sudo access + +### Setup + + + + + +```bash +sudo apt install apache2-utils +``` + + + + + +```bash +sudo htpasswd -c /etc/nginx/.openclaw_htpasswd admin +``` + +You'll be prompted to enter and confirm a password. This creates the file with a user called `admin`. + + + + + +Create (or edit) the Nginx config for OpenClaw: + +```bash +sudo nano /etc/nginx/sites-available/loglife-openclaw-admin +``` + +Set the `location /` block to: + +```nginx +location / { + auth_basic "OpenClaw Admin"; + auth_basic_user_file /etc/nginx/.openclaw_htpasswd; + + proxy_pass http://127.0.0.1:18789/; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + proxy_read_timeout 3600; + proxy_send_timeout 3600; +} +``` + +Enable the site and reload Nginx: + +```bash +sudo ln -s /etc/nginx/sites-available/loglife-openclaw-admin /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + + + + + +### Managing users + + + + + +```bash +sudo htpasswd /etc/nginx/.openclaw_htpasswd admin +``` + + + + + +```bash +sudo htpasswd /etc/nginx/.openclaw_htpasswd newuser +``` + + + + + +```bash +sudo htpasswd -D /etc/nginx/.openclaw_htpasswd olduser +``` + + + + + + + The `-c` flag creates a **new** file (overwriting any existing one). Only use it the first time. For subsequent users, omit `-c`. + From 235ffbb70dea237cc597f33e025fe66cfaf42f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sat, 21 Feb 2026 17:16:08 -0800 Subject: [PATCH 12/32] fine tune user flow to dashboard --- website/app/dashboard/page.tsx | 183 +++++++++++++++++++++++++++++++-- 1 file changed, 176 insertions(+), 7 deletions(-) diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index a2d87fa1..3f3357c8 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -50,6 +50,10 @@ export default function DashboardPage() { const [session, setSession] = useState(null); const [sessionLoading, setSessionLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [selectedService, setSelectedService] = useState<"whatsapp" | "telegram">("whatsapp"); + const [phoneNumber, setPhoneNumber] = useState(""); + const [sendingMessage, setSendingMessage] = useState(false); + const [sendMessageFeedback, setSendMessageFeedback] = useState(null); const menuRef = useRef(null); const whatsappSessionId = (user?.unsafeMetadata as Record | undefined)?.whatsappSessionId || ""; @@ -187,14 +191,179 @@ export default function DashboardPage() {
) : !session ? ( -
-
- - - +
+ {/* Onboarding Header */} +
+

Start logging now.

+

Connect your WhatsApp to begin journaling with LogLife.

+
+ + {/* Two-Column Cards */} +
+ + {/* Left Card — "You message us" */} + + + {/* Right Card — "We message you" (Recommended) */} +
+ {/* Recommended Badge */} +
+ + Recommended + +
+ +
+
+ + + +
+
+

Get messaged by LogLife

+

Fastest way to get started

+
+
+ +
+ {/* Phone Number Input */} +
+ +
+
+ + +
+ { + setPhoneNumber(e.target.value.replace(/[^0-9]/g, "")); + setSendMessageFeedback(null); + }} + className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600" + placeholder="1 555 123 4567" + /> +
+

Include your country code (e.g. 1 for US)

+
+ + {/* Info box */} +
+
+ + + +

+ We'll send you a WhatsApp message and connect your dashboard automatically — no extra steps needed. +

+
+
+ + {sendMessageFeedback && ( +
+ {sendMessageFeedback} +
+ )} + + +
+
+
-

No WhatsApp session found

-

Connect your WhatsApp account in Account Settings to see session data here.

+ + {/* Already have a session ID */} +

+ Already have a session ID?{" "} + + Connect manually in Account Settings + +

) : ( <> From dd96314f466c9f189b7f9c4b631dad8d5aa29d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 07:39:51 -0800 Subject: [PATCH 13/32] improve pricing page --- website/app/pricing/page.tsx | 432 ++++++++++++++++++++++------------- 1 file changed, 276 insertions(+), 156 deletions(-) diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index 4592011a..cf01f759 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useEffect, useRef, useCallback } from "react"; import Link from "next/link"; function CheckIcon({ className = "w-5 h-5" }: { className?: string }) { @@ -10,189 +10,301 @@ function CheckIcon({ className = "w-5 h-5" }: { className?: string }) { ); } -const SETUP_STEPS = [ - "Configure API keys", - "Provision server", - "Set up storage", - "Deploy application", - "Verify & monitor", +const oldSteps = [ + "Rent a Private Server", + "Create 5 API keys", + "Clone and install OpenClaw", + "Configure OpenClaw", + "Clone LogLife and install Plug-in", + "Launch web dashboard", ]; -function AnimatedComparison() { - const ref = useRef(null); - const [started, setStarted] = useState(false); - const [selfHostedCompleted, setSelfHostedCompleted] = useState(new Array(SETUP_STEPS.length).fill(false)); - const [hostedCompleted, setHostedCompleted] = useState(new Array(SETUP_STEPS.length).fill(false)); - const [selfHostedTime, setSelfHostedTime] = useState(0); - const [hostedTime, setHostedTime] = useState(0); - const [selfHostedDone, setSelfHostedDone] = useState(false); - const [hostedDone, setHostedDone] = useState(false); - - useEffect(() => { - const el = ref.current; - if (!el) return; - const obs = new IntersectionObserver( - ([entry]) => { if (entry.isIntersecting) { setStarted(true); obs.unobserve(el); } }, - { threshold: 0.3 } - ); - obs.observe(el); - return () => obs.disconnect(); - }, []); +const newSteps = [ + "Sign up", + "Start Messaging AI", + "See Habits in Dashboard", +]; - const selfHostedTarget = 9000; - const hostedTarget = 300; +interface ComparisonState { + oldActive: number[]; + oldDone: number[]; + oldTexts: string[]; + newActive: number[]; + newTexts: string[]; + oldTime: string; + newTime: string; + showSummary: boolean; +} - const formatTime = useCallback((ms: number, isSelfHosted: boolean) => { - if (isSelfHosted) { - const totalMinutes = Math.floor((ms / selfHostedTarget) * 150); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - return `${hours}h ${String(minutes).padStart(2, "0")}m`; +const INITIAL_STATE: ComparisonState = { + oldActive: [], + oldDone: [], + oldTexts: oldSteps.map(() => ""), + newActive: [], + newTexts: newSteps.map(() => ""), + oldTime: "0h 00m", + newTime: "0m 00s", + showSummary: false, +}; + +type ComparisonAction = + | { type: "RESET" } + | { type: "OLD_ACTIVATE"; idx: number } + | { type: "OLD_TYPE"; idx: number; text: string } + | { type: "OLD_DONE"; idx: number } + | { type: "OLD_TIME"; value: string } + | { type: "NEW_ACTIVATE"; idx: number } + | { type: "NEW_TIME"; value: string } + | { type: "SHOW_SUMMARY" }; + +function comparisonReducer(state: ComparisonState, action: ComparisonAction): ComparisonState { + switch (action.type) { + case "RESET": + return { ...INITIAL_STATE, oldTexts: oldSteps.map(() => ""), newTexts: newSteps.map(() => "") }; + case "OLD_ACTIVATE": + return { ...state, oldActive: [...state.oldActive, action.idx] }; + case "OLD_TYPE": { + const texts = [...state.oldTexts]; + texts[action.idx] = action.text; + return { ...state, oldTexts: texts }; } - const totalSeconds = Math.floor((ms / hostedTarget) * 300); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}m ${String(seconds).padStart(2, "0")}s`; - }, []); - - useEffect(() => { - if (!started) return; - - const stepDelay = selfHostedTarget / SETUP_STEPS.length; - const timers: ReturnType[] = []; - - SETUP_STEPS.forEach((_, i) => { - timers.push(setTimeout(() => { - setSelfHostedCompleted(prev => { const next = [...prev]; next[i] = true; return next; }); - }, stepDelay * (i + 1))); - }); - - timers.push(setTimeout(() => setSelfHostedDone(true), selfHostedTarget)); - - return () => timers.forEach(clearTimeout); - }, [started, selfHostedTarget]); - - useEffect(() => { - if (!started) return; + case "OLD_DONE": + return { ...state, oldDone: [...state.oldDone, action.idx] }; + case "OLD_TIME": + return { ...state, oldTime: action.value }; + case "NEW_ACTIVATE": { + const texts = [...state.newTexts]; + texts[action.idx] = newSteps[action.idx]; + return { ...state, newActive: [...state.newActive, action.idx], newTexts: texts }; + } + case "NEW_TIME": + return { ...state, newTime: action.value }; + case "SHOW_SUMMARY": + return { ...state, showSummary: true }; + default: + return state; + } +} - const timers: ReturnType[] = []; - SETUP_STEPS.forEach((_, i) => { - timers.push(setTimeout(() => { - setHostedCompleted(prev => { const next = [...prev]; next[i] = true; return next; }); - }, 80 * (i + 1))); - }); +function formatOldTime(s: number) { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h > 0) return `${h}h ${m < 10 ? "0" : ""}${m}m`; + const sec = s % 60; + return `${m}m ${sec < 10 ? "0" : ""}${sec}s`; +} - timers.push(setTimeout(() => setHostedDone(true), hostedTarget)); +function formatNewTime(s: number) { + const m = Math.floor(s / 60); + const sec = s % 60; + return `${m}m ${sec < 10 ? "0" : ""}${sec}s`; +} - return () => timers.forEach(clearTimeout); - }, [started, hostedTarget]); +function AnimatedComparison() { + const gridRef = useRef(null); + const [state, dispatch] = React.useReducer(comparisonReducer, INITIAL_STATE); + const runningRef = useRef(false); + const timersRef = useRef[]>([]); + + const clearTimers = useCallback(() => { + timersRef.current.forEach((id) => { clearInterval(id); clearTimeout(id); }); + timersRef.current = []; + }, []); - useEffect(() => { - if (!started || selfHostedDone) return; - const interval = setInterval(() => { - setSelfHostedTime(prev => { - if (prev >= selfHostedTarget) { clearInterval(interval); return selfHostedTarget; } - return prev + 50; - }); + const addTimer = useCallback((id: ReturnType) => { timersRef.current.push(id); }, []); + + const reset = useCallback(() => { + clearTimers(); + runningRef.current = false; + dispatch({ type: "RESET" }); + }, [clearTimers]); + + const run = useCallback(() => { + if (runningRef.current) return; + runningRef.current = true; + dispatch({ type: "RESET" }); + + const start = setTimeout(() => { + let oldSec = 0; + const oldClock = setInterval(() => { + oldSec += 47; + if (oldSec > 16200) oldSec = 16200; + dispatch({ type: "OLD_TIME", value: formatOldTime(oldSec) }); + if (oldSec >= 16200) clearInterval(oldClock); + }, 50); + addTimer(oldClock); + + function typeOldStep(idx: number) { + if (idx >= oldSteps.length) return; + dispatch({ type: "OLD_ACTIVATE", idx }); + const text = oldSteps[idx]; + let ci = 0; + const iv = setInterval(() => { + if (ci < text.length) { + ci++; + dispatch({ type: "OLD_TYPE", idx, text: text.slice(0, ci) }); + } else { + clearInterval(iv); + dispatch({ type: "OLD_DONE", idx }); + addTimer(setTimeout(() => typeOldStep(idx + 1), 600)); + } + }, 35); + addTimer(iv); + } + typeOldStep(0); + + addTimer( + setTimeout(() => { + let newSec = 0; + const newClock = setInterval(() => { + newSec += 1; + if (newSec > 90) newSec = 90; + dispatch({ type: "NEW_TIME", value: formatNewTime(newSec) }); + if (newSec >= 90) clearInterval(newClock); + }, 40); + addTimer(newClock); + + function showNewStep(idx: number) { + if (idx >= newSteps.length) { + addTimer(setTimeout(() => dispatch({ type: "SHOW_SUMMARY" }), 600)); + addTimer( + setTimeout(() => { + runningRef.current = false; + run(); + }, 8000) + ); + return; + } + dispatch({ type: "NEW_ACTIVATE", idx }); + addTimer(setTimeout(() => showNewStep(idx + 1), 350)); + } + showNewStep(0); + }, 2000) + ); }, 50); - return () => clearInterval(interval); - }, [started, selfHostedDone, selfHostedTarget]); + addTimer(start); + }, [addTimer]); useEffect(() => { - if (!started || hostedDone) return; - const interval = setInterval(() => { - setHostedTime(prev => { - if (prev >= hostedTarget) { clearInterval(interval); return hostedTarget; } - return prev + 50; - }); - }, 50); - return () => clearInterval(interval); - }, [started, hostedDone, hostedTarget]); + const grid = gridRef.current; + if (!grid) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !runningRef.current) run(); + else if (!entry.isIntersecting && runningRef.current) reset(); + }); + }, + { threshold: 0.3 } + ); + + observer.observe(grid); + return () => { observer.unobserve(grid); clearTimers(); }; + }, [run, reset, clearTimers]); return ( -
+
+ +
The Difference

Same product. Two paths.

+

+ See what changes when we handle the infrastructure. +

-
- {/* Self-Hosted panel */} -
-
-
- - - +
+ + {/* Self-Hosted (slow) */} +
+
+ Self-Hosted +
+ +
+
+ + + + Terminal
-
-

Self-Hosted

+
+ {oldSteps.map((_, i) => ( +
+ + {state.oldTexts[i]} +
+ ))}
-
- - {started ? formatTime(selfHostedTime, true) : "0h 00m"} - +
+ Time elapsed: + {state.oldTime}
+
-
- {SETUP_STEPS.map((step, i) => ( -
-
- {selfHostedCompleted[i] && ( - - - - )} -
- {step} -
- ))} + {/* Hosted (fast) */} +
+
+ Hosted by LogLife
-
- {/* Hosted panel */} -
-
-
- - - +
+
+ + + + LogLife
-
-

Hosted

+
+ {newSteps.map((_, i) => ( +
+ + {state.newTexts[i]} +
+ ))}
-
- - {started ? formatTime(hostedTime, false) : "0m 00s"} - -
- -
- {SETUP_STEPS.map((step, i) => ( -
-
- {hostedCompleted[i] && ( - - - - )} -
- {step} -
- ))} +
+ Time elapsed: + {state.newTime}
+
-

- Same product. Same features. Different setup time. -

+
+ + 180x + + + faster. Same product. Same features. + +
); } @@ -247,6 +359,12 @@ export default function PricingPage() { {item}
))} +
+ + + + Manual updates +
+
+ + Hosted private instance +
{[ - "Everything below, plus we handle the infrastructure:", - "Hosted private instance", - "API usage included (up to limit)", + "Included API usage", "Dashboard access", "Health telemetry integrations", - "Smart reminders", - "AI highlights (D/W/M/Q/Y)", - "Email support", - ].map((item, index) => ( + "No Maintenance, Always on", + "Automatic Updates of Latest Features", + "Priority Email Support", + ].map((item) => (
- - {item} + + {item}
))}
From e3a370f56654d2953a70790b074b883a02533450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 07:48:16 -0800 Subject: [PATCH 14/32] small fix for the animation counters --- website/app/pricing/page.tsx | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index cf01f759..b5d1bca1 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -125,13 +125,20 @@ function AnimatedComparison() { dispatch({ type: "RESET" }); const start = setTimeout(() => { - let oldSec = 0; + const CHAR_SPEED = 35; + const STEP_PAUSE = 600; + const totalTypingMs = oldSteps.reduce( + (sum, s, i) => sum + s.length * CHAR_SPEED + (i < oldSteps.length - 1 ? STEP_PAUSE : 0), 0 + ); + const OLD_TARGET = 16200; + + const clockStart = Date.now(); const oldClock = setInterval(() => { - oldSec += 47; - if (oldSec > 16200) oldSec = 16200; - dispatch({ type: "OLD_TIME", value: formatOldTime(oldSec) }); - if (oldSec >= 16200) clearInterval(oldClock); - }, 50); + const elapsed = Date.now() - clockStart; + const progress = Math.min(elapsed / totalTypingMs, 1); + dispatch({ type: "OLD_TIME", value: formatOldTime(Math.floor(progress * OLD_TARGET)) }); + if (progress >= 1) clearInterval(oldClock); + }, 30); addTimer(oldClock); function typeOldStep(idx: number) { @@ -146,9 +153,11 @@ function AnimatedComparison() { } else { clearInterval(iv); dispatch({ type: "OLD_DONE", idx }); - addTimer(setTimeout(() => typeOldStep(idx + 1), 600)); + if (idx < oldSteps.length - 1) { + addTimer(setTimeout(() => typeOldStep(idx + 1), STEP_PAUSE)); + } } - }, 35); + }, CHAR_SPEED); addTimer(iv); } typeOldStep(0); @@ -304,6 +313,9 @@ function AnimatedComparison() { faster. Same product. Same features. + + We handle the infrastructure, the API costs, and the updates. +
); From 861a98028165a86c4c540fdd0c3868a7a5b5bbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 07:49:53 -0800 Subject: [PATCH 15/32] change counter to h/m only --- website/app/pricing/page.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index b5d1bca1..bed8f513 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -60,7 +60,16 @@ type ComparisonAction = function comparisonReducer(state: ComparisonState, action: ComparisonAction): ComparisonState { switch (action.type) { case "RESET": - return { ...INITIAL_STATE, oldTexts: oldSteps.map(() => ""), newTexts: newSteps.map(() => "") }; + return { + oldActive: [], + oldDone: [], + oldTexts: oldSteps.map(() => ""), + newActive: [], + newTexts: newSteps.map(() => ""), + oldTime: "0h 00m", + newTime: "0m 00s", + showSummary: false, + }; case "OLD_ACTIVATE": return { ...state, oldActive: [...state.oldActive, action.idx] }; case "OLD_TYPE": { @@ -89,9 +98,7 @@ function comparisonReducer(state: ComparisonState, action: ComparisonAction): Co function formatOldTime(s: number) { const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); - if (h > 0) return `${h}h ${m < 10 ? "0" : ""}${m}m`; - const sec = s % 60; - return `${m}m ${sec < 10 ? "0" : ""}${sec}s`; + return `${h}h ${m < 10 ? "0" : ""}${m}m`; } function formatNewTime(s: number) { @@ -311,10 +318,10 @@ function AnimatedComparison() { 180x - faster. Same product. Same features. + faster setup. Same features. Always stable & up-to-date. - We handle the infrastructure, the API costs, and the updates. + We handle infrastructure, APIs, and updates. You focus on logging.
From cda3cd055045aafadf21f1744de5b8679a9f1794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 07:53:20 -0800 Subject: [PATCH 16/32] adjust timing of hosted animation --- website/app/pricing/page.tsx | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index bed8f513..5ae321d8 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -132,17 +132,18 @@ function AnimatedComparison() { dispatch({ type: "RESET" }); const start = setTimeout(() => { - const CHAR_SPEED = 35; - const STEP_PAUSE = 600; + const CHAR_SPEED = 45; + const STEP_PAUSE = 1000; const totalTypingMs = oldSteps.reduce( (sum, s, i) => sum + s.length * CHAR_SPEED + (i < oldSteps.length - 1 ? STEP_PAUSE : 0), 0 ); const OLD_TARGET = 16200; + const oldClockDuration = totalTypingMs + 400; const clockStart = Date.now(); const oldClock = setInterval(() => { const elapsed = Date.now() - clockStart; - const progress = Math.min(elapsed / totalTypingMs, 1); + const progress = Math.min(elapsed / oldClockDuration, 1); dispatch({ type: "OLD_TIME", value: formatOldTime(Math.floor(progress * OLD_TARGET)) }); if (progress >= 1) clearInterval(oldClock); }, 30); @@ -171,13 +172,17 @@ function AnimatedComparison() { addTimer( setTimeout(() => { - let newSec = 0; + const NEW_STEP_DELAY = 350; + const NEW_TARGET = 90; + const newTotalMs = newSteps.length * NEW_STEP_DELAY + 200; + + const newClockStart = Date.now(); const newClock = setInterval(() => { - newSec += 1; - if (newSec > 90) newSec = 90; - dispatch({ type: "NEW_TIME", value: formatNewTime(newSec) }); - if (newSec >= 90) clearInterval(newClock); - }, 40); + const elapsed = Date.now() - newClockStart; + const progress = Math.min(elapsed / newTotalMs, 1); + dispatch({ type: "NEW_TIME", value: formatNewTime(Math.floor(progress * NEW_TARGET)) }); + if (progress >= 1) clearInterval(newClock); + }, 30); addTimer(newClock); function showNewStep(idx: number) { @@ -192,7 +197,7 @@ function AnimatedComparison() { return; } dispatch({ type: "NEW_ACTIVATE", idx }); - addTimer(setTimeout(() => showNewStep(idx + 1), 350)); + addTimer(setTimeout(() => showNewStep(idx + 1), NEW_STEP_DELAY)); } showNewStep(0); }, 2000) @@ -318,7 +323,7 @@ function AnimatedComparison() { 180x - faster setup. Same features. Always stable & up-to-date. + faster setup. Always stable & up-to-date. We handle infrastructure, APIs, and updates. You focus on logging. From 0933a0b97bec7c0b451042127e097ed772d72a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 07:58:59 -0800 Subject: [PATCH 17/32] make flow sequential --- website/app/pricing/page.tsx | 82 ++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index 5ae321d8..3f1a3dfa 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -132,13 +132,11 @@ function AnimatedComparison() { dispatch({ type: "RESET" }); const start = setTimeout(() => { - const CHAR_SPEED = 45; - const STEP_PAUSE = 1000; - const totalTypingMs = oldSteps.reduce( - (sum, s, i) => sum + s.length * CHAR_SPEED + (i < oldSteps.length - 1 ? STEP_PAUSE : 0), 0 - ); + // --- Phase 1: Self-Hosted (left side types out fully, ~8s) --- + const CHAR_SPEED = 30; + const STEP_PAUSE = 800; const OLD_TARGET = 16200; - const oldClockDuration = totalTypingMs + 400; + const oldClockDuration = 8000; const clockStart = Date.now(); const oldClock = setInterval(() => { @@ -150,7 +148,11 @@ function AnimatedComparison() { addTimer(oldClock); function typeOldStep(idx: number) { - if (idx >= oldSteps.length) return; + if (idx >= oldSteps.length) { + // Phase 1 done → pause → start Phase 2 + addTimer(setTimeout(startHosted, 500)); + return; + } dispatch({ type: "OLD_ACTIVATE", idx }); const text = oldSteps[idx]; let ci = 0; @@ -161,47 +163,45 @@ function AnimatedComparison() { } else { clearInterval(iv); dispatch({ type: "OLD_DONE", idx }); - if (idx < oldSteps.length - 1) { - addTimer(setTimeout(() => typeOldStep(idx + 1), STEP_PAUSE)); - } + addTimer(setTimeout(() => typeOldStep(idx + 1), STEP_PAUSE)); } }, CHAR_SPEED); addTimer(iv); } typeOldStep(0); - addTimer( - setTimeout(() => { - const NEW_STEP_DELAY = 350; - const NEW_TARGET = 90; - const newTotalMs = newSteps.length * NEW_STEP_DELAY + 200; - - const newClockStart = Date.now(); - const newClock = setInterval(() => { - const elapsed = Date.now() - newClockStart; - const progress = Math.min(elapsed / newTotalMs, 1); - dispatch({ type: "NEW_TIME", value: formatNewTime(Math.floor(progress * NEW_TARGET)) }); - if (progress >= 1) clearInterval(newClock); - }, 30); - addTimer(newClock); - - function showNewStep(idx: number) { - if (idx >= newSteps.length) { - addTimer(setTimeout(() => dispatch({ type: "SHOW_SUMMARY" }), 600)); - addTimer( - setTimeout(() => { - runningRef.current = false; - run(); - }, 8000) - ); - return; - } - dispatch({ type: "NEW_ACTIVATE", idx }); - addTimer(setTimeout(() => showNewStep(idx + 1), NEW_STEP_DELAY)); + // --- Phase 2: Hosted (right side, starts after left finishes) --- + function startHosted() { + const NEW_STEP_DELAY = 800; + const NEW_TARGET = 90; + const newTotalMs = 2500; + + const newClockStart = Date.now(); + const newClock = setInterval(() => { + const elapsed = Date.now() - newClockStart; + const progress = Math.min(elapsed / newTotalMs, 1); + dispatch({ type: "NEW_TIME", value: formatNewTime(Math.floor(progress * NEW_TARGET)) }); + if (progress >= 1) clearInterval(newClock); + }, 30); + addTimer(newClock); + + function showNewStep(idx: number) { + if (idx >= newSteps.length) { + // Phase 2 done → pause → show punchline + addTimer(setTimeout(() => dispatch({ type: "SHOW_SUMMARY" }), 500)); + addTimer( + setTimeout(() => { + runningRef.current = false; + run(); + }, 8000) + ); + return; } - showNewStep(0); - }, 2000) - ); + dispatch({ type: "NEW_ACTIVATE", idx }); + addTimer(setTimeout(() => showNewStep(idx + 1), NEW_STEP_DELAY)); + } + showNewStep(0); + } }, 50); addTimer(start); }, [addTimer]); From 780812aebce876e06cd242b9308c400cc999d53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 09:25:27 -0800 Subject: [PATCH 18/32] add mintlify rules --- .cursor/rules.md | 395 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 .cursor/rules.md diff --git a/.cursor/rules.md b/.cursor/rules.md new file mode 100644 index 00000000..7d985262 --- /dev/null +++ b/.cursor/rules.md @@ -0,0 +1,395 @@ +# Mintlify technical writing rule + +You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. + +## Core writing principles + +### Language and style requirements + +- Use clear, direct language appropriate for technical audiences +- Write in second person ("you") for instructions and procedures +- Use active voice over passive voice +- Employ present tense for current states, future tense for outcomes +- Avoid jargon unless necessary and define terms when first used +- Maintain consistent terminology throughout all documentation +- Keep sentences concise while providing necessary context +- Use parallel structure in lists, headings, and procedures + +### Content organization standards + +- Lead with the most important information (inverted pyramid structure) +- Use progressive disclosure: basic concepts before advanced ones +- Break complex procedures into numbered steps +- Include prerequisites and context before instructions +- Provide expected outcomes for each major step +- Use descriptive, keyword-rich headings for navigation and SEO +- Group related information logically with clear section breaks + +### User-centered approach + +- Focus on user goals and outcomes rather than system features +- Anticipate common questions and address them proactively +- Include troubleshooting for likely failure points +- Write for scannability with clear headings, lists, and white space +- Include verification steps to confirm success + +## Mintlify component reference + +### Callout components + +#### Note - Additional helpful information + + +Supplementary information that supports the main content without interrupting flow + + +#### Tip - Best practices and pro tips + + +Expert advice, shortcuts, or best practices that enhance user success + + +#### Warning - Important cautions + + +Critical information about potential issues, breaking changes, or destructive actions + + +#### Info - Neutral contextual information + + +Background information, context, or neutral announcements + + +#### Check - Success confirmations + + +Positive confirmations, successful completions, or achievement indicators + + +### Code components + +#### Single code block + +Example of a single code block: + +```javascript config.js +const apiConfig = { + baseURL: 'https://api.example.com', + timeout: 5000, + headers: { + 'Authorization': `Bearer ${process.env.API_TOKEN}` + } +}; +``` + +#### Code group with multiple languages + +Example of a code group: + + +```javascript Node.js +const response = await fetch('/api/endpoint', { + headers: { Authorization: `Bearer ${apiKey}` } +}); +``` + +```python Python +import requests +response = requests.get('/api/endpoint', + headers={'Authorization': f'Bearer {api_key}'}) +``` + +```curl cURL +curl -X GET '/api/endpoint' \ + -H 'Authorization: Bearer YOUR_API_KEY' +``` + + +#### Request/response examples + +Example of request/response documentation: + + +```bash cURL +curl -X POST 'https://api.example.com/users' \ + -H 'Content-Type: application/json' \ + -d '{"name": "John Doe", "email": "john@example.com"}' +``` + + + +```json Success +{ + "id": "user_123", + "name": "John Doe", + "email": "john@example.com", + "created_at": "2024-01-15T10:30:00Z" +} +``` + + +### Structural components + +#### Steps for procedures + +Example of step-by-step instructions: + + + + Run `npm install` to install required packages. + + + Verify installation by running `npm list`. + + + + + Create a `.env` file with your API credentials. + + ```bash + API_KEY=your_api_key_here + ``` + + + Never commit API keys to version control. + + + + +#### Tabs for alternative content + +Example of tabbed content: + + + + ```bash + brew install node + npm install -g package-name + ``` + + + + ```powershell + choco install nodejs + npm install -g package-name + ``` + + + + ```bash + sudo apt install nodejs npm + npm install -g package-name + ``` + + + +#### Accordions for collapsible content + +Example of accordion groups: + + + + - **Firewall blocking**: Ensure ports 80 and 443 are open + - **Proxy configuration**: Set HTTP_PROXY environment variable + - **DNS resolution**: Try using 8.8.8.8 as DNS server + + + + ```javascript + const config = { + performance: { cache: true, timeout: 30000 }, + security: { encryption: 'AES-256' } + }; + ``` + + + +### Cards and columns for emphasizing information + +Example of cards and card groups: + + +Complete walkthrough from installation to your first API call in under 10 minutes. + + + + + Learn how to authenticate requests using API keys or JWT tokens. + + + + Understand rate limits and best practices for high-volume usage. + + + +### API documentation components + +#### Parameter fields + +Example of parameter documentation: + + +Unique identifier for the user. Must be a valid UUID v4 format. + + + +User's email address. Must be valid and unique within the system. + + + +Maximum number of results to return. Range: 1-100. + + + +Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` + + +#### Response fields + +Example of response field documentation: + + +Unique identifier assigned to the newly created user. + + + +ISO 8601 formatted timestamp of when the user was created. + + + +List of permission strings assigned to this user. + + +#### Expandable nested fields + +Example of nested field documentation: + + +Complete user object with all associated data. + + + + User profile information including personal details. + + + + User's first name as entered during registration. + + + + URL to user's profile picture. Returns null if no avatar is set. + + + + + + +### Media and advanced components + +#### Frames for images + +Wrap all images in frames: + + +Main dashboard showing analytics overview + + + +Analytics dashboard with charts + + +#### Videos + +Use the HTML video element for self-hosted video content: + + + +Embed YouTube videos using iframe elements: + + + +#### Tooltips + +Example of tooltip usage: + + +API + + +#### Updates + +Use updates for changelogs: + + +## New features +- Added bulk user import functionality +- Improved error messages with actionable suggestions + +## Bug fixes +- Fixed pagination issue with large datasets +- Resolved authentication timeout problems + + +## Required page structure + +Every documentation page must begin with YAML frontmatter: + +```yaml +--- +title: "Clear, specific, keyword-rich title" +description: "Concise description explaining page purpose and value" +--- +``` + +## Content quality standards + +### Code examples requirements + +- Always include complete, runnable examples that users can copy and execute +- Show proper error handling and edge case management +- Use realistic data instead of placeholder values +- Include expected outputs and results for verification +- Test all code examples thoroughly before publishing +- Specify language and include filename when relevant +- Add explanatory comments for complex logic +- Never include real API keys or secrets in code examples + +### API documentation requirements + +- Document all parameters including optional ones with clear descriptions +- Show both success and error response examples with realistic data +- Include rate limiting information with specific limits +- Provide authentication examples showing proper format +- Explain all HTTP status codes and error handling +- Cover complete request/response cycles + +### Accessibility requirements + +- Include descriptive alt text for all images and diagrams +- Use specific, actionable link text instead of "click here" +- Ensure proper heading hierarchy starting with H2 +- Provide keyboard navigation considerations +- Use sufficient color contrast in examples and visuals +- Structure content for easy scanning with headers and lists + +## Component selection logic + +- Use **Steps** for procedures and sequential instructions +- Use **Tabs** for platform-specific content or alternative approaches +- Use **CodeGroup** when showing the same concept in multiple programming languages +- Use **Accordions** for progressive disclosure of information +- Use **RequestExample/ResponseExample** specifically for API endpoint documentation +- Use **ParamField** for API parameters, **ResponseField** for API responses +- Use **Expandable** for nested object properties or hierarchical information \ No newline at end of file From a933857d09d5ef22af20aa8163bb64af673880a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 09:40:27 -0800 Subject: [PATCH 19/32] fix sidebar order --- website/app/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/app/components/Sidebar.tsx b/website/app/components/Sidebar.tsx index 7c72d444..13701be6 100644 --- a/website/app/components/Sidebar.tsx +++ b/website/app/components/Sidebar.tsx @@ -115,8 +115,8 @@ export default function Sidebar() { const authNavItemsMain = [ { href: "/", icon: (), label: "Home" }, { href: "/features", icon: (), label: "Features" }, - { href: "/blog", icon: (), label: "Blog" }, { href: "/pricing", icon: (), label: "Pricing" }, + { href: "/blog", icon: (), label: "Blog" }, { href: "https://docs.loglife.co/", icon: (), label: "Docs" }, ]; const authNavItemsUser = [ From cd46b647e45554a265b4001a4cdda0bc68939d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 09:53:45 -0800 Subject: [PATCH 20/32] add documentation section to mintlify --- docs/contributing-docs.mdx | 44 ++++++++++++++++++++++++++++++++++++++ docs/docs.json | 3 ++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 docs/contributing-docs.mdx diff --git a/docs/contributing-docs.mdx b/docs/contributing-docs.mdx new file mode 100644 index 00000000..26c11734 --- /dev/null +++ b/docs/contributing-docs.mdx @@ -0,0 +1,44 @@ +--- +title: "Contributing to docs" +description: "How to update, preview, and deploy the LogLife documentation site." +--- + +## Overview + +The docs live in the `docs/` directory of the LogLife monorepo and are built with [Mintlify](https://mintlify.com). They are deployed automatically to [docs.loglife.co](https://docs.loglife.co) whenever changes are merged into `main`. + +## How to update + +All pages are `.mdx` files in `docs/`. The full Mintlify syntax reference is stored in `.cursor/rules.md` at the repo root, so if you're using an AI editor you can just ask it to make changes and it will follow the correct syntax. + +To add or edit a page manually: + +1. Create or edit an `.mdx` file in `docs/`. +2. Add [frontmatter](https://mintlify.com/docs/page) at the top of the file (`title`, `description`). +3. If you're adding a new page, register it in `docs/docs.json` under the appropriate navigation group. + +## How to preview locally + +```bash +cd loglife/docs +mintlify dev +``` + +This starts a local server (usually at `http://localhost:3000`) with hot reload. Changes to `.mdx` files are reflected immediately. + + + You need the Mintlify CLI installed globally: `npm i -g mintlify`. + + +## Where it shows up + +| Environment | URL | Trigger | +|---|---|---| +| Local preview | `localhost:3000` | `mintlify dev` | +| Production | [docs.loglife.co](https://docs.loglife.co) | Merge to `main` | + +Once your changes are merged into the `main` branch, Mintlify automatically rebuilds and deploys the site. No manual deploy step is needed. + +## How to improve the docs + +Get [inspiration](https://www.mintlify.com/customers) from other teams using Mintlify. diff --git a/docs/docs.json b/docs/docs.json index ffc5be73..9f327656 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -20,7 +20,8 @@ "quickstart", "self-hosting", "openclaw-tricks", - "development" + "development", + "contributing-docs" ] }, { From ae8030d8e9d76cabe2b566c8ea5ed7c90fc02237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 10:13:46 -0800 Subject: [PATCH 21/32] Update deployment workflow and documentation for LogLife plugin - Changed the deployment step name to "Deploy plugin and restart gateway" for clarity. - Added health checks to the deployment process to verify plugin functionality after restart. - Updated the development documentation to reflect the new local setup instructions and API endpoint details. - Removed outdated AI tools documentation and unnecessary sections to streamline content. - Revised the API reference to include new endpoints for session management and verification. --- .github/workflows/deploy.yml | 37 +- docs/ai-tools/claude-code.mdx | 76 ---- docs/ai-tools/cursor.mdx | 420 ------------------- docs/ai-tools/windsurf.mdx | 96 ----- docs/api-reference/endpoint/create.mdx | 4 - docs/api-reference/endpoint/delete.mdx | 4 - docs/api-reference/endpoint/get-sessions.mdx | 4 + docs/api-reference/endpoint/get.mdx | 4 - docs/api-reference/endpoint/verify-check.mdx | 4 + docs/api-reference/endpoint/verify-send.mdx | 4 + docs/api-reference/endpoint/webhook.mdx | 4 - docs/api-reference/introduction.mdx | 59 ++- docs/api-reference/openapi.json | 297 ++++++------- docs/development.mdx | 100 ++--- docs/docs.json | 63 +-- docs/essentials/code.mdx | 35 -- docs/essentials/images.mdx | 59 --- docs/essentials/markdown.mdx | 88 ---- docs/essentials/navigation.mdx | 87 ---- docs/essentials/reusable-snippets.mdx | 110 ----- docs/essentials/settings.mdx | 318 -------------- plugin/index.ts | 243 ++++++++++- website/app/account/page.tsx | 167 +------- website/app/api/sessions/route.ts | 6 +- website/app/api/verify/route.ts | 84 ++++ website/app/dashboard/page.tsx | 313 +++++++------- 26 files changed, 793 insertions(+), 1893 deletions(-) delete mode 100644 docs/ai-tools/claude-code.mdx delete mode 100644 docs/ai-tools/cursor.mdx delete mode 100644 docs/ai-tools/windsurf.mdx delete mode 100644 docs/api-reference/endpoint/create.mdx delete mode 100644 docs/api-reference/endpoint/delete.mdx create mode 100644 docs/api-reference/endpoint/get-sessions.mdx delete mode 100644 docs/api-reference/endpoint/get.mdx create mode 100644 docs/api-reference/endpoint/verify-check.mdx create mode 100644 docs/api-reference/endpoint/verify-send.mdx delete mode 100644 docs/api-reference/endpoint/webhook.mdx delete mode 100644 docs/essentials/code.mdx delete mode 100644 docs/essentials/images.mdx delete mode 100644 docs/essentials/markdown.mdx delete mode 100644 docs/essentials/navigation.mdx delete mode 100644 docs/essentials/reusable-snippets.mdx delete mode 100644 docs/essentials/settings.mdx create mode 100644 website/app/api/verify/route.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 432868c5..6b2dada0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - - name: Update plugin on server + - name: Deploy plugin and restart gateway uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} @@ -30,5 +30,36 @@ jobs: git checkout main git reset --hard origin/main - # Uncomment to auto-restart the gateway after deploying plugin changes: - # openclaw gateway restart + # Restart the gateway so it loads the updated plugin code + openclaw gateway restart + + # Wait for gateway to come back up + sleep 5 + + # Health check: verify the LogLife plugin is loaded and responding + SESSIONS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $LOGLIFE_API_KEY" \ + "http://localhost:18789/loglife/sessions?phone=healthcheck" || echo "000") + + VERIFY_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -X POST -H "Authorization: Bearer $LOGLIFE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"phone":"0","code":"000000"}' \ + "http://localhost:18789/loglife/verify/check" || echo "000") + + echo "Plugin health check: sessions=$SESSIONS_STATUS verify=$VERIFY_STATUS" + + # 404 = plugin loaded, searched, found nothing (expected) + # 200 = plugin loaded, returned data (also fine) + if [ "$SESSIONS_STATUS" != "404" ] && [ "$SESSIONS_STATUS" != "200" ]; then + echo "ERROR: Sessions endpoint returned unexpected status $SESSIONS_STATUS" + exit 1 + fi + + # 200 = plugin loaded, returned {verified:false} (expected) + if [ "$VERIFY_STATUS" != "200" ]; then + echo "ERROR: Verify endpoint returned unexpected status $VERIFY_STATUS" + exit 1 + fi + + echo "All health checks passed." diff --git a/docs/ai-tools/claude-code.mdx b/docs/ai-tools/claude-code.mdx deleted file mode 100644 index bdc4e04b..00000000 --- a/docs/ai-tools/claude-code.mdx +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: "Claude Code setup" -description: "Configure Claude Code for your documentation workflow" -icon: "asterisk" ---- - -Claude Code is Anthropic's official CLI tool. This guide will help you set up Claude Code to help you write and maintain your documentation. - -## Prerequisites - -- Active Claude subscription (Pro, Max, or API access) - -## Setup - -1. Install Claude Code globally: - - ```bash - npm install -g @anthropic-ai/claude-code -``` - -2. Navigate to your docs directory. -3. (Optional) Add the `CLAUDE.md` file below to your project. -4. Run `claude` to start. - -## Create `CLAUDE.md` - -Create a `CLAUDE.md` file at the root of your documentation repository to train Claude Code on your specific documentation standards: - -````markdown -# Mintlify documentation - -## Working relationship -- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so -- ALWAYS ask for clarification rather than making assumptions -- NEVER lie, guess, or make up information - -## Project context -- Format: MDX files with YAML frontmatter -- Config: docs.json for navigation, theme, settings -- Components: Mintlify components - -## Content strategy -- Document just enough for user success - not too much, not too little -- Prioritize accuracy and usability of information -- Make content evergreen when possible -- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason -- Check existing patterns for consistency -- Start by making the smallest reasonable changes - -## Frontmatter requirements for pages -- title: Clear, descriptive page title -- description: Concise summary for SEO/navigation - -## Writing standards -- Second-person voice ("you") -- Prerequisites at start of procedural content -- Test all code examples before publishing -- Match style and formatting of existing pages -- Include both basic and advanced use cases -- Language tags on all code blocks -- Alt text on all images -- Relative paths for internal links - -## Git workflow -- NEVER use --no-verify when committing -- Ask how to handle uncommitted changes before starting -- Create a new branch when no clear branch exists for changes -- Commit frequently throughout development -- NEVER skip or disable pre-commit hooks - -## Do not -- Skip frontmatter on any MDX file -- Use absolute URLs for internal links -- Include untested code examples -- Make assumptions - always ask for clarification -```` diff --git a/docs/ai-tools/cursor.mdx b/docs/ai-tools/cursor.mdx deleted file mode 100644 index fbb77616..00000000 --- a/docs/ai-tools/cursor.mdx +++ /dev/null @@ -1,420 +0,0 @@ ---- -title: "Cursor setup" -description: "Configure Cursor for your documentation workflow" -icon: "arrow-pointer" ---- - -Use Cursor to help write and maintain your documentation. This guide shows how to configure Cursor for better results on technical writing tasks and using Mintlify components. - -## Prerequisites - -- Cursor editor installed -- Access to your documentation repository - -## Project rules - -Create project rules that all team members can use. In your documentation repository root: - -```bash -mkdir -p .cursor -``` - -Create `.cursor/rules.md`: - -````markdown -# Mintlify technical writing rule - -You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. - -## Core writing principles - -### Language and style requirements - -- Use clear, direct language appropriate for technical audiences -- Write in second person ("you") for instructions and procedures -- Use active voice over passive voice -- Employ present tense for current states, future tense for outcomes -- Avoid jargon unless necessary and define terms when first used -- Maintain consistent terminology throughout all documentation -- Keep sentences concise while providing necessary context -- Use parallel structure in lists, headings, and procedures - -### Content organization standards - -- Lead with the most important information (inverted pyramid structure) -- Use progressive disclosure: basic concepts before advanced ones -- Break complex procedures into numbered steps -- Include prerequisites and context before instructions -- Provide expected outcomes for each major step -- Use descriptive, keyword-rich headings for navigation and SEO -- Group related information logically with clear section breaks - -### User-centered approach - -- Focus on user goals and outcomes rather than system features -- Anticipate common questions and address them proactively -- Include troubleshooting for likely failure points -- Write for scannability with clear headings, lists, and white space -- Include verification steps to confirm success - -## Mintlify component reference - -### Callout components - -#### Note - Additional helpful information - - -Supplementary information that supports the main content without interrupting flow - - -#### Tip - Best practices and pro tips - - -Expert advice, shortcuts, or best practices that enhance user success - - -#### Warning - Important cautions - - -Critical information about potential issues, breaking changes, or destructive actions - - -#### Info - Neutral contextual information - - -Background information, context, or neutral announcements - - -#### Check - Success confirmations - - -Positive confirmations, successful completions, or achievement indicators - - -### Code components - -#### Single code block - -Example of a single code block: - -```javascript config.js -const apiConfig = { - baseURL: 'https://api.example.com', - timeout: 5000, - headers: { - 'Authorization': `Bearer ${process.env.API_TOKEN}` - } -}; -``` - -#### Code group with multiple languages - -Example of a code group: - - -```javascript Node.js -const response = await fetch('/api/endpoint', { - headers: { Authorization: `Bearer ${apiKey}` } -}); -``` - -```python Python -import requests -response = requests.get('/api/endpoint', - headers={'Authorization': f'Bearer {api_key}'}) -``` - -```curl cURL -curl -X GET '/api/endpoint' \ - -H 'Authorization: Bearer YOUR_API_KEY' -``` - - -#### Request/response examples - -Example of request/response documentation: - - -```bash cURL -curl -X POST 'https://api.example.com/users' \ - -H 'Content-Type: application/json' \ - -d '{"name": "John Doe", "email": "john@example.com"}' -``` - - - -```json Success -{ - "id": "user_123", - "name": "John Doe", - "email": "john@example.com", - "created_at": "2024-01-15T10:30:00Z" -} -``` - - -### Structural components - -#### Steps for procedures - -Example of step-by-step instructions: - - - - Run `npm install` to install required packages. - - - Verify installation by running `npm list`. - - - - - Create a `.env` file with your API credentials. - - ```bash - API_KEY=your_api_key_here - ``` - - - Never commit API keys to version control. - - - - -#### Tabs for alternative content - -Example of tabbed content: - - - - ```bash - brew install node - npm install -g package-name - ``` - - - - ```powershell - choco install nodejs - npm install -g package-name - ``` - - - - ```bash - sudo apt install nodejs npm - npm install -g package-name - ``` - - - -#### Accordions for collapsible content - -Example of accordion groups: - - - - - **Firewall blocking**: Ensure ports 80 and 443 are open - - **Proxy configuration**: Set HTTP_PROXY environment variable - - **DNS resolution**: Try using 8.8.8.8 as DNS server - - - - ```javascript - const config = { - performance: { cache: true, timeout: 30000 }, - security: { encryption: 'AES-256' } - }; - ``` - - - -### Cards and columns for emphasizing information - -Example of cards and card groups: - - -Complete walkthrough from installation to your first API call in under 10 minutes. - - - - - Learn how to authenticate requests using API keys or JWT tokens. - - - - Understand rate limits and best practices for high-volume usage. - - - -### API documentation components - -#### Parameter fields - -Example of parameter documentation: - - -Unique identifier for the user. Must be a valid UUID v4 format. - - - -User's email address. Must be valid and unique within the system. - - - -Maximum number of results to return. Range: 1-100. - - - -Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` - - -#### Response fields - -Example of response field documentation: - - -Unique identifier assigned to the newly created user. - - - -ISO 8601 formatted timestamp of when the user was created. - - - -List of permission strings assigned to this user. - - -#### Expandable nested fields - -Example of nested field documentation: - - -Complete user object with all associated data. - - - - User profile information including personal details. - - - - User's first name as entered during registration. - - - - URL to user's profile picture. Returns null if no avatar is set. - - - - - - -### Media and advanced components - -#### Frames for images - -Wrap all images in frames: - - -Main dashboard showing analytics overview - - - -Analytics dashboard with charts - - -#### Videos - -Use the HTML video element for self-hosted video content: - - - -Embed YouTube videos using iframe elements: - - - -#### Tooltips - -Example of tooltip usage: - - -API - - -#### Updates - -Use updates for changelogs: - - -## New features -- Added bulk user import functionality -- Improved error messages with actionable suggestions - -## Bug fixes -- Fixed pagination issue with large datasets -- Resolved authentication timeout problems - - -## Required page structure - -Every documentation page must begin with YAML frontmatter: - -```yaml ---- -title: "Clear, specific, keyword-rich title" -description: "Concise description explaining page purpose and value" ---- -``` - -## Content quality standards - -### Code examples requirements - -- Always include complete, runnable examples that users can copy and execute -- Show proper error handling and edge case management -- Use realistic data instead of placeholder values -- Include expected outputs and results for verification -- Test all code examples thoroughly before publishing -- Specify language and include filename when relevant -- Add explanatory comments for complex logic -- Never include real API keys or secrets in code examples - -### API documentation requirements - -- Document all parameters including optional ones with clear descriptions -- Show both success and error response examples with realistic data -- Include rate limiting information with specific limits -- Provide authentication examples showing proper format -- Explain all HTTP status codes and error handling -- Cover complete request/response cycles - -### Accessibility requirements - -- Include descriptive alt text for all images and diagrams -- Use specific, actionable link text instead of "click here" -- Ensure proper heading hierarchy starting with H2 -- Provide keyboard navigation considerations -- Use sufficient color contrast in examples and visuals -- Structure content for easy scanning with headers and lists - -## Component selection logic - -- Use **Steps** for procedures and sequential instructions -- Use **Tabs** for platform-specific content or alternative approaches -- Use **CodeGroup** when showing the same concept in multiple programming languages -- Use **Accordions** for progressive disclosure of information -- Use **RequestExample/ResponseExample** specifically for API endpoint documentation -- Use **ParamField** for API parameters, **ResponseField** for API responses -- Use **Expandable** for nested object properties or hierarchical information -```` diff --git a/docs/ai-tools/windsurf.mdx b/docs/ai-tools/windsurf.mdx deleted file mode 100644 index fce12bfd..00000000 --- a/docs/ai-tools/windsurf.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: "Windsurf setup" -description: "Configure Windsurf for your documentation workflow" -icon: "water" ---- - -Configure Windsurf's Cascade AI assistant to help you write and maintain documentation. This guide shows how to set up Windsurf specifically for your Mintlify documentation workflow. - -## Prerequisites - -- Windsurf editor installed -- Access to your documentation repository - -## Workspace rules - -Create workspace rules that provide Windsurf with context about your documentation project and standards. - -Create `.windsurf/rules.md` in your project root: - -````markdown -# Mintlify technical writing rule - -## Project context - -- This is a documentation project on the Mintlify platform -- We use MDX files with YAML frontmatter -- Navigation is configured in `docs.json` -- We follow technical writing best practices - -## Writing standards - -- Use second person ("you") for instructions -- Write in active voice and present tense -- Start procedures with prerequisites -- Include expected outcomes for major steps -- Use descriptive, keyword-rich headings -- Keep sentences concise but informative - -## Required page structure - -Every page must start with frontmatter: - -```yaml ---- -title: "Clear, specific title" -description: "Concise description for SEO and navigation" ---- -``` - -## Mintlify components - -### Callouts - -- `` for helpful supplementary information -- `` for important cautions and breaking changes -- `` for best practices and expert advice -- `` for neutral contextual information -- `` for success confirmations - -### Code examples - -- When appropriate, include complete, runnable examples -- Use `` for multiple language examples -- Specify language tags on all code blocks -- Include realistic data, not placeholders -- Use `` and `` for API docs - -### Procedures - -- Use `` component for sequential instructions -- Include verification steps with `` components when relevant -- Break complex procedures into smaller steps - -### Content organization - -- Use `` for platform-specific content -- Use `` for progressive disclosure -- Use `` and `` for highlighting content -- Wrap images in `` components with descriptive alt text - -## API documentation requirements - -- Document all parameters with `` -- Show response structure with `` -- Include both success and error examples -- Use `` for nested object properties -- Always include authentication examples - -## Quality standards - -- Test all code examples before publishing -- Use relative paths for internal links -- Include alt text for all images -- Ensure proper heading hierarchy (start with h2) -- Check existing patterns for consistency -```` diff --git a/docs/api-reference/endpoint/create.mdx b/docs/api-reference/endpoint/create.mdx deleted file mode 100644 index 5689f1b6..00000000 --- a/docs/api-reference/endpoint/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: 'Create Plant' -openapi: 'POST /plants' ---- diff --git a/docs/api-reference/endpoint/delete.mdx b/docs/api-reference/endpoint/delete.mdx deleted file mode 100644 index 657dfc87..00000000 --- a/docs/api-reference/endpoint/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: 'Delete Plant' -openapi: 'DELETE /plants/{id}' ---- diff --git a/docs/api-reference/endpoint/get-sessions.mdx b/docs/api-reference/endpoint/get-sessions.mdx new file mode 100644 index 00000000..cde879f0 --- /dev/null +++ b/docs/api-reference/endpoint/get-sessions.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Session" +openapi: "GET /loglife/sessions" +--- diff --git a/docs/api-reference/endpoint/get.mdx b/docs/api-reference/endpoint/get.mdx deleted file mode 100644 index 56aa09ec..00000000 --- a/docs/api-reference/endpoint/get.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: 'Get Plants' -openapi: 'GET /plants' ---- diff --git a/docs/api-reference/endpoint/verify-check.mdx b/docs/api-reference/endpoint/verify-check.mdx new file mode 100644 index 00000000..597688c8 --- /dev/null +++ b/docs/api-reference/endpoint/verify-check.mdx @@ -0,0 +1,4 @@ +--- +title: "Check Verification Code" +openapi: "POST /loglife/verify/check" +--- diff --git a/docs/api-reference/endpoint/verify-send.mdx b/docs/api-reference/endpoint/verify-send.mdx new file mode 100644 index 00000000..8a531e43 --- /dev/null +++ b/docs/api-reference/endpoint/verify-send.mdx @@ -0,0 +1,4 @@ +--- +title: "Send Verification Code" +openapi: "POST /loglife/verify/send" +--- diff --git a/docs/api-reference/endpoint/webhook.mdx b/docs/api-reference/endpoint/webhook.mdx deleted file mode 100644 index 32913402..00000000 --- a/docs/api-reference/endpoint/webhook.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: 'New Plant' -openapi: 'WEBHOOK /plant/webhook' ---- diff --git a/docs/api-reference/introduction.mdx b/docs/api-reference/introduction.mdx index c835b78b..48488fd0 100644 --- a/docs/api-reference/introduction.mdx +++ b/docs/api-reference/introduction.mdx @@ -1,33 +1,46 @@ --- -title: 'Introduction' -description: 'Example section for showcasing API endpoints' +title: "Introduction" +description: "The LogLife plugin exposes an HTTP API inside the OpenClaw gateway for session data and phone verification." --- - - If you're not looking to build API reference documentation, you can delete - this section by removing the api-reference folder. - +## Overview -## Welcome +The LogLife plugin registers three HTTP routes on the OpenClaw gateway: -There are two ways to build API documentation: [OpenAPI](https://mintlify.com/docs/api-playground/openapi/setup) and [MDX components](https://mintlify.com/docs/api-playground/mdx/configuration). For the starter kit, we are using the following OpenAPI specification. - - - View the OpenAPI specification file - +| Endpoint | Method | Purpose | +|---|---|---| +| `/loglife/sessions` | GET | Look up session data by phone, session ID, or key | +| `/loglife/verify/send` | POST | Send a 6-digit verification code via WhatsApp | +| `/loglife/verify/check` | POST | Validate a verification code | ## Authentication -All API endpoints are authenticated using Bearer tokens and picked up from the specification file. +All endpoints require a **Bearer token** in the `Authorization` header: -```json -"security": [ - { - "bearerAuth": [] - } -] ``` +Authorization: Bearer +``` + +The API key is configured in `~/.openclaw/openclaw.json` under `plugins.entries.loglife.config.apiKey`. Generate one with: + +```bash +openssl rand -hex 32 +``` + +## Architecture + +The API runs inside the OpenClaw gateway process — there is no separate service. The LogLife dashboard (hosted on Vercel) calls these endpoints through its own Next.js API routes, which add the Bearer token server-side. End users never interact with the plugin API directly. + +``` +Browser → Next.js API route → LogLife Plugin (OpenClaw gateway) +``` + +## Security model + +- **Bearer token auth** on every request (timing-safe comparison) +- **Clerk auth** on the Next.js proxy layer (only logged-in users) +- **Rate limiting** on verification code sends (1 per phone per 60s) +- **Single-use codes** deleted immediately after successful verification +- **5-minute TTL** on verification codes + +Documenting these endpoints publicly is safe because knowing the URL structure and parameters is useless without the API key, which is only stored server-side. diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index da5326ef..f85214ce 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -1,16 +1,14 @@ { "openapi": "3.1.0", "info": { - "title": "OpenAPI Plant Store", - "description": "A sample API that uses a plant store as an example to demonstrate features in the OpenAPI specification", - "license": { - "name": "MIT" - }, - "version": "1.0.0" + "title": "LogLife Plugin API", + "version": "0.1.0", + "description": "HTTP API exposed by the LogLife plugin running inside the OpenClaw gateway. All endpoints require Bearer token authentication." }, "servers": [ { - "url": "http://sandbox.mintlify.com" + "url": "http://localhost:18789", + "description": "Local development" } ], "security": [ @@ -18,200 +16,215 @@ "bearerAuth": [] } ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "The API key configured in openclaw.json under plugins.entries.loglife.config.apiKey" + } + }, + "schemas": { + "Session": { + "type": "object", + "properties": { + "sessionKey": { "type": "string" }, + "sessionId": { "type": "string" }, + "updatedAt": { "type": "number", "description": "Unix timestamp (ms)" }, + "abortedLastRun": { "type": "boolean" }, + "chatType": { "type": "string" }, + "lastChannel": { "type": "string" }, + "origin": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "from": { "type": "string" }, + "to": { "type": "string" } + } + }, + "deliveryContext": { + "type": "object", + "properties": { + "channel": { "type": "string" }, + "to": { "type": "string" } + } + }, + "compactionCount": { "type": "integer" }, + "inputTokens": { "type": "integer" }, + "outputTokens": { "type": "integer" }, + "totalTokens": { "type": "integer" }, + "model": { "type": "string" } + } + }, + "Error": { + "type": "object", + "properties": { + "error": { "type": "string" } + }, + "required": ["error"] + } + } + }, "paths": { - "/plants": { + "/loglife/sessions": { "get": { - "description": "Returns all plants from the system that the user has access to", + "operationId": "getSessions", + "summary": "Get session data", + "description": "Look up a single session by phone number, session ID, or session key. At least one query parameter is required.", "parameters": [ { - "name": "limit", + "name": "phone", "in": "query", - "description": "The maximum number of results to return", - "schema": { - "type": "integer", - "format": "int32" - } + "schema": { "type": "string" }, + "description": "Phone number (e.g. +15551234567). Matches against origin.from in sessions.json.", + "example": "+15551234567" + }, + { + "name": "sessionId", + "in": "query", + "schema": { "type": "string" }, + "description": "Session UUID" + }, + { + "name": "key", + "in": "query", + "schema": { "type": "string" }, + "description": "Session key (the top-level key in sessions.json)" } ], "responses": { "200": { - "description": "Plant response", + "description": "Session found", "content": { "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plant" - } - } + "schema": { "$ref": "#/components/schemas/Session" } } } }, "400": { - "description": "Unexpected error", + "description": "Missing query parameter", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "schema": { "$ref": "#/components/schemas/Error" } + } + } + }, + "401": { + "description": "Unauthorized — missing or invalid API key" + }, + "404": { + "description": "Session not found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } } } } } - }, + } + }, + "/loglife/verify/send": { "post": { - "description": "Creates a new plant in the store", + "operationId": "verifySend", + "summary": "Send verification code", + "description": "Generates a 6-digit verification code and sends it to the specified phone number via WhatsApp. Rate limited to one code per phone number per 60 seconds. Codes expire after 5 minutes.", "requestBody": { - "description": "Plant to add to the store", + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NewPlant" + "type": "object", + "required": ["phone"], + "properties": { + "phone": { + "type": "string", + "description": "Phone number to send the code to", + "example": "+15551234567" + } + } } } - }, - "required": true + } }, "responses": { "200": { - "description": "plant response", + "description": "Code sent successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Plant" + "type": "object", + "properties": { + "sent": { "type": "boolean", "example": true } + } } } } }, "400": { - "description": "unexpected error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/plants/{id}": { - "delete": { - "description": "Deletes a single plant based on the ID supplied", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of plant to delete", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "responses": { - "204": { - "description": "Plant deleted", - "content": {} + "description": "Missing or invalid phone number" }, - "400": { - "description": "unexpected error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "401": { + "description": "Unauthorized" + }, + "429": { + "description": "Rate limited — code already sent within 60 seconds" + }, + "502": { + "description": "Failed to send message via gateway" } } } - } - }, - "webhooks": { - "/plant/webhook": { + }, + "/loglife/verify/check": { "post": { - "description": "Information about a new plant added to the store", + "operationId": "verifyCheck", + "summary": "Validate verification code", + "description": "Checks whether the provided code matches the one sent to the given phone number. Uses timing-safe comparison. Codes are single-use — deleted after successful verification.", "requestBody": { - "description": "Plant added to the store", + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NewPlant" + "type": "object", + "required": ["phone", "code"], + "properties": { + "phone": { + "type": "string", + "example": "+15551234567" + }, + "code": { + "type": "string", + "description": "6-digit verification code", + "example": "482910" + } + } } } } }, "responses": { "200": { - "description": "Return a 200 status to indicate that the data was received successfully" - } - } - } - } - }, - "components": { - "schemas": { - "Plant": { - "required": [ - "name" - ], - "type": "object", - "properties": { - "name": { - "description": "The name of the plant", - "type": "string" - }, - "tag": { - "description": "Tag to specify the type", - "type": "string" - } - } - }, - "NewPlant": { - "allOf": [ - { - "$ref": "#/components/schemas/Plant" - }, - { - "required": [ - "id" - ], - "type": "object", - "properties": { - "id": { - "description": "Identification number of the plant", - "type": "integer", - "format": "int64" + "description": "Verification result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "verified": { "type": "boolean" }, + "error": { "type": "string", "description": "Present when verified is false" } + } + } } } - } - ] - }, - "Error": { - "required": [ - "error", - "message" - ], - "type": "object", - "properties": { - "error": { - "type": "integer", - "format": "int32" }, - "message": { - "type": "string" + "400": { + "description": "Missing required fields" + }, + "401": { + "description": "Unauthorized" } } } - }, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer" - } } } -} \ No newline at end of file +} diff --git a/docs/development.mdx b/docs/development.mdx index ac633bad..5e2e58f4 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -1,94 +1,76 @@ --- title: 'Development' -description: 'Preview changes locally to update your docs' +description: 'Local development workflow for the LogLife website and plugin' --- +## Local setup + **Prerequisites**: - - Node.js version 19 or higher - - A docs repository with a `docs.json` file + - Node.js 19+, pnpm + - OpenClaw installed (see [Self-hosting](/self-hosting)) -Follow these steps to install and run Mintlify on your operating system. - - + ```bash -npm i -g mint +cd ~/openclaw +./openclaw.mjs gateway --allow-unconfigured ``` - - - - -Navigate to your docs directory where your `docs.json` file is located, and run the following command: - -```bash -mint dev -``` - -A local preview of your documentation will be available at `http://localhost:3000`. +The gateway runs on port 18789 by default. The LogLife plugin is loaded automatically if installed (see [Self-hosting](/self-hosting) for plugin installation). - - -## Custom ports -By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. For example, to run Mintlify on port 3333, use this command: + ```bash -mint dev --port 3333 +cd loglife/website +pnpm install +pnpm dev ``` -If you attempt to run Mintlify on a port that's already in use, it will use the next available port: +The dashboard is at `http://localhost:3000/dashboard`. + -```md -Port 3000 is already in use. Trying 3001 instead. -``` + -## Mintlify versions +When you change `plugin/index.ts`, restart the local gateway to pick up the changes. The website hot-reloads automatically. + + -Please note that each CLI release is associated with a specific version of Mintlify. If your local preview does not align with the production version, please update the CLI: +## Plugin deployment -```bash -npm mint update -``` +When plugin changes are pushed to `main`, the GitHub Actions workflow: -## Validating links +1. SSHes into the production server +2. Pulls the latest code +3. Restarts the OpenClaw gateway (graceful — waits for in-flight replies to drain) +4. Runs a health check against the plugin endpoints -The CLI can assist with validating links in your documentation. To identify any broken links, use the following command: +**Sessions are not lost on restart** — they are persisted to disk in `sessions.json`. In-memory verification codes are cleared, but that's expected (5-minute TTL, users simply re-request). -```bash -mint broken-links -``` - -## Deployment +### Health checks -If the deployment is successful, you should see the following: +The deploy workflow verifies two things after each restart: - - Screenshot of a deployment confirmation message that says All checks have passed. - +- `GET /loglife/sessions?phone=healthcheck` returns 404 (plugin loaded, searched, found nothing) +- `POST /loglife/verify/check` with dummy data returns 200 (verify endpoint loaded) -## Code formatting +If either check fails, the deploy is marked as failed in GitHub Actions. -We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting. +### Website deployment -## Troubleshooting +The website deploys to Vercel automatically on push to `main`. No gateway restart needed — the website is a separate deployment that connects to the plugin via `OPENCLAW_API_URL`. - - +## Docs preview - This may be due to an outdated version of node. Try the following: - 1. Remove the currently-installed version of the CLI: `npm remove -g mint` - 2. Upgrade to Node v19 or higher. - 3. Reinstall the CLI: `npm i -g mint` - +To preview documentation changes locally: - - - Solution: Go to the root of your device and delete the `~/.mintlify` folder. Then run `mint dev` again. - - +```bash +npm i -g mint +cd docs +mint dev +``` -Curious about what changed in the latest CLI version? Check out the [CLI changelog](https://www.npmjs.com/package/mintlify?activeTab=versions). +A local preview will be available at `http://localhost:3000`. diff --git a/docs/docs.json b/docs/docs.json index 9f327656..60a8346a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1,7 +1,7 @@ { "$schema": "https://mintlify.com/docs.json", "theme": "mint", - "name": "Mint Starter Kit", + "name": "LogLife", "colors": { "primary": "#16A34A", "light": "#07C983", @@ -23,30 +23,6 @@ "development", "contributing-docs" ] - }, - { - "group": "Customization", - "pages": [ - "essentials/settings", - "essentials/navigation" - ] - }, - { - "group": "Writing content", - "pages": [ - "essentials/markdown", - "essentials/code", - "essentials/images", - "essentials/reusable-snippets" - ] - }, - { - "group": "AI tools", - "pages": [ - "ai-tools/cursor", - "ai-tools/claude-code", - "ai-tools/windsurf" - ] } ] }, @@ -60,12 +36,11 @@ ] }, { - "group": "Endpoint examples", + "group": "Endpoints", "pages": [ - "api-reference/endpoint/get", - "api-reference/endpoint/create", - "api-reference/endpoint/delete", - "api-reference/endpoint/webhook" + "api-reference/endpoint/get-sessions", + "api-reference/endpoint/verify-send", + "api-reference/endpoint/verify-check" ] } ] @@ -74,13 +49,18 @@ "global": { "anchors": [ { - "anchor": "Documentation", - "href": "https://mintlify.com/docs", - "icon": "book-open-cover" + "anchor": "Website", + "href": "https://loglife.co", + "icon": "house" + }, + { + "anchor": "Repository", + "href": "https://github.com/jmoraispk/loglife", + "icon": "github" }, { "anchor": "Blog", - "href": "https://mintlify.com/blog", + "href": "https://loglife.co/blog", "icon": "newspaper" } ] @@ -88,19 +68,20 @@ }, "logo": { "light": "/logo/light.svg", - "dark": "/logo/dark.svg" + "dark": "/logo/dark.svg", + "href": "https://loglife.co" }, "navbar": { "links": [ { "label": "Support", - "href": "mailto:hi@mintlify.com" + "href": "mailto:support@loglife.com" } ], "primary": { "type": "button", "label": "Dashboard", - "href": "https://dashboard.mintlify.com" + "href": "https://loglife.com/dashboard" } }, "contextual": { @@ -115,11 +96,5 @@ "vscode" ] }, - "footer": { - "socials": { - "x": "https://x.com/mintlify", - "github": "https://github.com/mintlify", - "linkedin": "https://linkedin.com/company/mintlify" - } - } + "footer": {} } diff --git a/docs/essentials/code.mdx b/docs/essentials/code.mdx deleted file mode 100644 index ae2abbfe..00000000 --- a/docs/essentials/code.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: 'Code blocks' -description: 'Display inline code and code blocks' -icon: 'code' ---- - -## Inline code - -To denote a `word` or `phrase` as code, enclose it in backticks (`). - -``` -To denote a `word` or `phrase` as code, enclose it in backticks (`). -``` - -## Code blocks - -Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language. - -```java HelloWorld.java -class HelloWorld { - public static void main(String[] args) { - System.out.println("Hello, World!"); - } -} -``` - -````md -```java HelloWorld.java -class HelloWorld { - public static void main(String[] args) { - System.out.println("Hello, World!"); - } -} -``` -```` diff --git a/docs/essentials/images.mdx b/docs/essentials/images.mdx deleted file mode 100644 index 1144eb2c..00000000 --- a/docs/essentials/images.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: 'Images and embeds' -description: 'Add image, video, and other HTML elements' -icon: 'image' ---- - - - -## Image - -### Using Markdown - -The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code - -```md -![title](/path/image.jpg) -``` - -Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed. - -### Using embeds - -To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images - -```html - -``` - -## Embeds and HTML elements - - - -
- - - -Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility. - - - -### iFrames - -Loads another HTML page within the document. Most commonly used for embedding videos. - -```html - -``` diff --git a/docs/essentials/markdown.mdx b/docs/essentials/markdown.mdx deleted file mode 100644 index a45c1d56..00000000 --- a/docs/essentials/markdown.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: 'Markdown syntax' -description: 'Text, title, and styling in standard markdown' -icon: 'text-size' ---- - -## Titles - -Best used for section headers. - -```md -## Titles -``` - -### Subtitles - -Best used for subsection headers. - -```md -### Subtitles -``` - - - -Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right. - - - -## Text formatting - -We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it. - -| Style | How to write it | Result | -| ------------- | ----------------- | --------------- | -| Bold | `**bold**` | **bold** | -| Italic | `_italic_` | _italic_ | -| Strikethrough | `~strikethrough~` | ~strikethrough~ | - -You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text. - -You need to use HTML to write superscript and subscript text. That is, add `` or `` around your text. - -| Text Size | How to write it | Result | -| ----------- | ------------------------ | ---------------------- | -| Superscript | `superscript` | superscript | -| Subscript | `subscript` | subscript | - -## Linking to pages - -You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com). - -Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section. - -Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily. - -## Blockquotes - -### Singleline - -To create a blockquote, add a `>` in front of a paragraph. - -> Dorothy followed her through many of the beautiful rooms in her castle. - -```md -> Dorothy followed her through many of the beautiful rooms in her castle. -``` - -### Multiline - -> Dorothy followed her through many of the beautiful rooms in her castle. -> -> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. - -```md -> Dorothy followed her through many of the beautiful rooms in her castle. -> -> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood. -``` - -### LaTeX - -Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component. - -8 x (vk x H1 - H2) = (0,1) - -```md -8 x (vk x H1 - H2) = (0,1) -``` diff --git a/docs/essentials/navigation.mdx b/docs/essentials/navigation.mdx deleted file mode 100644 index 60adeff2..00000000 --- a/docs/essentials/navigation.mdx +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: 'Navigation' -description: 'The navigation field in docs.json defines the pages that go in the navigation menu' -icon: 'map' ---- - -The navigation menu is the list of links on every website. - -You will likely update `docs.json` every time you add a new page. Pages do not show up automatically. - -## Navigation syntax - -Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names. - - - -```json Regular Navigation -"navigation": { - "tabs": [ - { - "tab": "Docs", - "groups": [ - { - "group": "Getting Started", - "pages": ["quickstart"] - } - ] - } - ] -} -``` - -```json Nested Navigation -"navigation": { - "tabs": [ - { - "tab": "Docs", - "groups": [ - { - "group": "Getting Started", - "pages": [ - "quickstart", - { - "group": "Nested Reference Pages", - "pages": ["nested-reference-page"] - } - ] - } - ] - } - ] -} -``` - - - -## Folders - -Simply put your MDX files in folders and update the paths in `docs.json`. - -For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`. - - - -You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted. - - - -```json Navigation With Folder -"navigation": { - "tabs": [ - { - "tab": "Docs", - "groups": [ - { - "group": "Group Name", - "pages": ["your-folder/your-page"] - } - ] - } - ] -} -``` - -## Hidden pages - -MDX files not included in `docs.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them. diff --git a/docs/essentials/reusable-snippets.mdx b/docs/essentials/reusable-snippets.mdx deleted file mode 100644 index 376e27bd..00000000 --- a/docs/essentials/reusable-snippets.mdx +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: "Reusable snippets" -description: "Reusable, custom snippets to keep content in sync" -icon: "recycle" ---- - -import SnippetIntro from '/snippets/snippet-intro.mdx'; - - - -## Creating a custom snippet - -**Pre-condition**: You must create your snippet file in the `snippets` directory. - - - Any page in the `snippets` directory will be treated as a snippet and will not - be rendered into a standalone page. If you want to create a standalone page - from the snippet, import the snippet into another file and call it as a - component. - - -### Default export - -1. Add content to your snippet file that you want to re-use across multiple - locations. Optionally, you can add variables that can be filled in via props - when you import the snippet. - -```mdx snippets/my-snippet.mdx -Hello world! This is my content I want to reuse across pages. My keyword of the -day is {word}. -``` - - - The content that you want to reuse must be inside the `snippets` directory in - order for the import to work. - - -2. Import the snippet into your destination file. - -```mdx destination-file.mdx ---- -title: My title -description: My Description ---- - -import MySnippet from '/snippets/path/to/my-snippet.mdx'; - -## Header - -Lorem impsum dolor sit amet. - - -``` - -### Reusable variables - -1. Export a variable from your snippet file: - -```mdx snippets/path/to/custom-variables.mdx -export const myName = 'my name'; - -export const myObject = { fruit: 'strawberries' }; -``` - -2. Import the snippet from your destination file and use the variable: - -```mdx destination-file.mdx ---- -title: My title -description: My Description ---- - -import { myName, myObject } from '/snippets/path/to/custom-variables.mdx'; - -Hello, my name is {myName} and I like {myObject.fruit}. -``` - -### Reusable components - -1. Inside your snippet file, create a component that takes in props by exporting - your component in the form of an arrow function. - -```mdx snippets/custom-component.mdx -export const MyComponent = ({ title }) => ( -
-

{title}

-

... snippet content ...

-
-); -``` - - - MDX does not compile inside the body of an arrow function. Stick to HTML - syntax when you can or use a default export if you need to use MDX. - - -2. Import the snippet into your destination file and pass in the props - -```mdx destination-file.mdx ---- -title: My title -description: My Description ---- - -import { MyComponent } from '/snippets/custom-component.mdx'; - -Lorem ipsum dolor sit amet. - - -``` diff --git a/docs/essentials/settings.mdx b/docs/essentials/settings.mdx deleted file mode 100644 index 884de13a..00000000 --- a/docs/essentials/settings.mdx +++ /dev/null @@ -1,318 +0,0 @@ ---- -title: 'Global Settings' -description: 'Mintlify gives you complete control over the look and feel of your documentation using the docs.json file' -icon: 'gear' ---- - -Every Mintlify site needs a `docs.json` file with the core configuration settings. Learn more about the [properties](#properties) below. - -## Properties - - -Name of your project. Used for the global title. - -Example: `mintlify` - - - - - An array of groups with all the pages within that group - - - The name of the group. - - Example: `Settings` - - - - The relative paths to the markdown files that will serve as pages. - - Example: `["customization", "page"]` - - - - - - - - Path to logo image or object with path to "light" and "dark" mode logo images - - - Path to the logo in light mode - - - Path to the logo in dark mode - - - Where clicking on the logo links you to - - - - - - Path to the favicon image - - - - Hex color codes for your global theme - - - The primary color. Used for most often for highlighted content, section - headers, accents, in light mode - - - The primary color for dark mode. Used for most often for highlighted - content, section headers, accents, in dark mode - - - The primary color for important buttons - - - The color of the background in both light and dark mode - - - The hex color code of the background in light mode - - - The hex color code of the background in dark mode - - - - - - - - Array of `name`s and `url`s of links you want to include in the topbar - - - The name of the button. - - Example: `Contact us` - - - The url once you click on the button. Example: `https://mintlify.com/docs` - - - - - - - - - Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars. - - - If `link`: What the button links to. - - If `github`: Link to the repository to load GitHub information from. - - - Text inside the button. Only required if `type` is a `link`. - - - - - - - Array of version names. Only use this if you want to show different versions - of docs with a dropdown in the navigation bar. - - - - An array of the anchors, includes the `icon`, `color`, and `url`. - - - The [Font Awesome](https://fontawesome.com/search?q=heart) icon used to feature the anchor. - - Example: `comments` - - - The name of the anchor label. - - Example: `Community` - - - The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in. - - - The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color. - - - Used if you want to hide an anchor until the correct docs version is selected. - - - Pass `true` if you want to hide the anchor until you directly link someone to docs inside it. - - - One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin" - - - - - - - Override the default configurations for the top-most anchor. - - - The name of the top-most anchor - - - Font Awesome icon. - - - One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin" - - - - - - An array of navigational tabs. - - - The name of the tab label. - - - The start of the URL that marks what pages go in the tab. Generally, this - is the name of the folder you put your pages in. - - - - - - Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo). - - - The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url - options that the user can toggle. - - - - - - The authentication strategy used for all API endpoints. - - - The name of the authentication parameter used in the API playground. - - If method is `basic`, the format should be `[usernameName]:[passwordName]` - - - The default value that's designed to be a prefix for the authentication input field. - - E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`. - - - - - - Configurations for the API playground - - - - Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple` - - Learn more at the [playground guides](/api-playground/demo) - - - - - - Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file. - - This behavior will soon be enabled by default, at which point this field will be deprecated. - - - - - - - A string or an array of strings of URL(s) or relative path(s) pointing to your - OpenAPI file. - - Examples: - - ```json Absolute - "openapi": "https://example.com/openapi.json" - ``` - ```json Relative - "openapi": "/openapi.json" - ``` - ```json Multiple - "openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"] - ``` - - - - - - An object of social media accounts where the key:property pair represents the social media platform and the account url. - - Example: - ```json - { - "x": "https://x.com/mintlify", - "website": "https://mintlify.com" - } - ``` - - - One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news` - - Example: `x` - - - The URL to the social platform. - - Example: `https://x.com/mintlify` - - - - - - Configurations to enable feedback buttons - - - - Enables a button to allow users to suggest edits via pull requests - - - Enables a button to allow users to raise an issue about the documentation - - - - - - Customize the dark mode toggle. - - - Set if you always want to show light or dark mode for new users. When not - set, we default to the same mode as the user's operating system. - - - Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example: - - - ```json Only Dark Mode - "modeToggle": { - "default": "dark", - "isHidden": true - } - ``` - - ```json Only Light Mode - "modeToggle": { - "default": "light", - "isHidden": true - } - ``` - - - - - - - - - A background image to be displayed behind every page. See example with - [Infisical](https://infisical.com/docs) and [FRPC](https://frpc.io). - diff --git a/plugin/index.ts b/plugin/index.ts index 19204817..d6112951 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,15 +1,32 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { timingSafeEqual } from "node:crypto"; +import { timingSafeEqual, randomInt } from "node:crypto"; import { URL } from "node:url"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { WebSocket } from "ws"; type LogLifeConfig = { apiKey: string; agentId?: string; }; +type VerificationEntry = { + code: string; + expiresAt: number; + sentAt: number; +}; + +const VERIFY_TTL_MS = 5 * 60 * 1000; +const VERIFY_COOLDOWN_MS = 60 * 1000; + +const verificationCodes = new Map(); + +function normalizePhone(raw: string): string { + const digits = raw.replace(/[^0-9]/g, ""); + return "+" + digits; +} + function verifyApiKey(req: IncomingMessage, expectedKey: string): boolean { const auth = req.headers.authorization ?? ""; const prefix = "Bearer "; @@ -23,12 +40,92 @@ function verifyApiKey(req: IncomingMessage, expectedKey: string): boolean { } } +function safeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); + } catch { + return false; + } +} + function jsonResponse(res: ServerResponse, status: number, body: unknown): void { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(body)); } +async function readBody(req: IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8"))); + } catch { + reject(new Error("Invalid JSON")); + } + }); + req.on("error", reject); + }); +} + +function sendViaGateway( + port: number, + authToken: string | undefined, + to: string, + message: string, +): Promise<{ ok: boolean; error?: string }> { + return new Promise((resolve) => { + const url = `ws://127.0.0.1:${port}`; + const ws = new WebSocket(url); + const reqId = crypto.randomUUID(); + const timeout = setTimeout(() => { + ws.close(); + resolve({ ok: false, error: "Gateway timeout" }); + }, 10_000); + + ws.on("open", () => { + if (authToken) { + ws.send(JSON.stringify({ + type: "req", + id: crypto.randomUUID(), + method: "auth", + params: { token: authToken }, + })); + } + + ws.send(JSON.stringify({ + type: "req", + id: reqId, + method: "send", + params: { + to, + message, + channel: "whatsapp", + idempotencyKey: crypto.randomUUID(), + }, + })); + }); + + ws.on("message", (data: Buffer) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.id === reqId && msg.type === "res") { + clearTimeout(timeout); + ws.close(); + resolve({ ok: !!msg.ok, error: msg.error?.message }); + } + } catch { /* ignore non-JSON frames */ } + }); + + ws.on("error", () => { + clearTimeout(timeout); + resolve({ ok: false, error: "Gateway connection failed" }); + }); + }); +} + const plugin = { id: "loglife", name: "LogLife", @@ -48,13 +145,23 @@ const plugin = { const agentId = cfg.agentId ?? "main"; if (!apiKey) { - api.logger.warn("LogLife plugin: apiKey not configured — HTTP route will reject all requests"); + api.logger.warn("LogLife plugin: apiKey not configured — HTTP routes will reject all requests"); } const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(process.env.HOME ?? "/root", ".openclaw"); const sessionsPath = join(stateDir, "agents", agentId, "sessions", "sessions.json"); + const gatewayPort = (api.config as Record)?.gateway + ? ((api.config as Record>).gateway.port as number) ?? 18789 + : 18789; + const gatewayAuth = (api.config as Record)?.gateway + ? ((api.config as Record>).gateway.auth as Record) + : undefined; + const gatewayToken = gatewayAuth?.token as string | undefined; + + // --- GET /loglife/sessions --- + api.registerHttpRoute({ path: "/loglife/sessions", handler: async (req: IncomingMessage, res: ServerResponse) => { @@ -71,9 +178,10 @@ const plugin = { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const sessionId = url.searchParams.get("sessionId"); const key = url.searchParams.get("key"); + const phone = url.searchParams.get("phone"); - if (!sessionId && !key) { - jsonResponse(res, 400, { error: "Provide ?sessionId= or ?key=" }); + if (!sessionId && !key && !phone) { + jsonResponse(res, 400, { error: "Provide ?sessionId=, ?key=, or ?phone=" }); return; } @@ -94,6 +202,17 @@ const plugin = { break; } } + } else if (phone) { + const normalized = normalizePhone(phone); + for (const [k, v] of Object.entries(sessions)) { + const origin = v.origin as Record | undefined; + const from = origin?.from ?? ""; + if (normalizePhone(from) === normalized) { + session = v; + matchedKey = k; + break; + } + } } if (!session) { @@ -131,6 +250,122 @@ const plugin = { } }, }); + + // --- POST /loglife/verify/send --- + + api.registerHttpRoute({ + path: "/loglife/verify/send", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + let body: Record; + try { + body = await readBody(req); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return; + } + + const phoneRaw = body.phone as string | undefined; + if (!phoneRaw || typeof phoneRaw !== "string") { + jsonResponse(res, 400, { error: "Missing required field: phone" }); + return; + } + + const phone = normalizePhone(phoneRaw); + if (phone.length < 8) { + jsonResponse(res, 400, { error: "Invalid phone number" }); + return; + } + + const existing = verificationCodes.get(phone); + if (existing && Date.now() - existing.sentAt < VERIFY_COOLDOWN_MS) { + const retryIn = Math.ceil((VERIFY_COOLDOWN_MS - (Date.now() - existing.sentAt)) / 1000); + jsonResponse(res, 429, { error: `Too many requests. Try again in ${retryIn}s` }); + return; + } + + const code = String(randomInt(100_000, 999_999)); + verificationCodes.set(phone, { + code, + expiresAt: Date.now() + VERIFY_TTL_MS, + sentAt: Date.now(), + }); + + const message = `Your LogLife verification code is: ${code}`; + const result = await sendViaGateway(gatewayPort, gatewayToken, phone, message); + + if (!result.ok) { + verificationCodes.delete(phone); + jsonResponse(res, 502, { error: result.error ?? "Failed to send message" }); + return; + } + + jsonResponse(res, 200, { sent: true }); + }, + }); + + // --- POST /loglife/verify/check --- + + api.registerHttpRoute({ + path: "/loglife/verify/check", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + let body: Record; + try { + body = await readBody(req); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return; + } + + const phoneRaw = body.phone as string | undefined; + const codeInput = body.code as string | undefined; + + if (!phoneRaw || typeof phoneRaw !== "string") { + jsonResponse(res, 400, { error: "Missing required field: phone" }); + return; + } + if (!codeInput || typeof codeInput !== "string") { + jsonResponse(res, 400, { error: "Missing required field: code" }); + return; + } + + const phone = normalizePhone(phoneRaw); + const entry = verificationCodes.get(phone); + + if (!entry || Date.now() > entry.expiresAt) { + verificationCodes.delete(phone); + jsonResponse(res, 200, { verified: false, error: "Code expired or not found" }); + return; + } + + if (!safeCompare(entry.code, codeInput.trim())) { + jsonResponse(res, 200, { verified: false, error: "Invalid code" }); + return; + } + + verificationCodes.delete(phone); + jsonResponse(res, 200, { verified: true }); + }, + }); }, }; diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index 8eb02319..f41c2b13 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -23,13 +23,8 @@ export default function AccountPage() { const [confirmPassword, setConfirmPassword] = useState(""); const [passwordLoading, setPasswordLoading] = useState(false); const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); - const [showWhatsAppModal, setShowWhatsAppModal] = useState(false); - const [whatsAppSessionId, setWhatsAppSessionId] = useState(""); - const [whatsAppLinking, setWhatsAppLinking] = useState(false); - const [whatsAppMessage, setWhatsAppMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); - - const storedSessionId = (user?.unsafeMetadata as Record | undefined)?.whatsappSessionId || ""; - const whatsAppConnected = !!storedSessionId; + const whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; + const whatsAppConnected = !!whatsappPhone; React.useEffect(() => { if (user) { @@ -125,47 +120,23 @@ export default function AccountPage() { } }; - const handleWhatsAppLink = async (e: React.FormEvent) => { - e.preventDefault(); - const sid = whatsAppSessionId.trim(); - if (!sid) return; - - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(sid)) { - setWhatsAppMessage({ type: "error", text: "Invalid session ID format. Expected a UUID like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }); - return; - } - - setWhatsAppLinking(true); - setWhatsAppMessage(null); - - try { - await user!.update({ - unsafeMetadata: { ...user!.unsafeMetadata, whatsappSessionId: sid }, - }); - setWhatsAppMessage({ type: "success", text: "WhatsApp session linked successfully!" }); - setTimeout(() => { - setShowWhatsAppModal(false); - setWhatsAppSessionId(""); - setWhatsAppMessage(null); - }, 1500); - } catch { - setWhatsAppMessage({ type: "error", text: "Failed to save session ID. Please try again." }); - } finally { - setWhatsAppLinking(false); - } - }; - const handleWhatsAppDisconnect = async () => { try { await user!.update({ - unsafeMetadata: { ...user!.unsafeMetadata, whatsappSessionId: undefined }, + unsafeMetadata: { ...user!.unsafeMetadata, whatsappPhone: undefined }, }); } catch { // silently fail } }; + function maskPhone(phone: string): string { + if (phone.length <= 4) return phone; + const last4 = phone.slice(-4); + const prefix = phone.slice(0, phone.length - 4).replace(/./g, "*"); + return prefix + last4; + } + const primaryEmail = user.emailAddresses.find( (email) => email.id === user.primaryEmailAddressId ); @@ -399,14 +370,14 @@ export default function AccountPage() {
WhatsApp {whatsAppConnected && ( - {storedSessionId.slice(0, 8)}... + {maskPhone(whatsappPhone)} )}
{whatsAppConnected ? (
- Connected + Verified
) : ( - + Verify on Dashboard + )}
@@ -563,112 +534,6 @@ export default function AccountPage() {
)} - {/* WhatsApp Link Modal */} - {showWhatsAppModal && ( -
-
{ - setShowWhatsAppModal(false); - setWhatsAppSessionId(""); - setWhatsAppMessage(null); - }} - /> -
-
-
- - - -
-
-

Connect WhatsApp

-

Link your WhatsApp session to Loglife

-
-
- - {/* How it works steps */} -
-

How it works

-
-
- 1 -

Start a conversation with the Loglife WhatsApp bot

-
-
- 2 -

Get your session ID from the bot or your admin

-
-
- 3 -

Paste the session ID below to link your account

-
-
-
- - {whatsAppMessage && ( -
- {whatsAppMessage.text} -
- )} - -
-
- - setWhatsAppSessionId(e.target.value.trim())} - className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600 font-mono" - placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - autoFocus - /> -

Paste the session UUID from your WhatsApp bot

-
- -
- - -
-
-
-
- )} - {/* Password Change Modal */} {showPasswordModal && (
diff --git a/website/app/api/sessions/route.ts b/website/app/api/sessions/route.ts index 8fdba17c..2fc40dc0 100644 --- a/website/app/api/sessions/route.ts +++ b/website/app/api/sessions/route.ts @@ -13,14 +13,16 @@ export async function GET(req: NextRequest) { const sessionId = req.nextUrl.searchParams.get("sessionId"); const key = req.nextUrl.searchParams.get("key"); + const phone = req.nextUrl.searchParams.get("phone"); - if (!sessionId && !key) { - return NextResponse.json({ error: "Provide ?sessionId= or ?key=" }, { status: 400 }); + if (!sessionId && !key && !phone) { + return NextResponse.json({ error: "Provide ?sessionId=, ?key=, or ?phone=" }, { status: 400 }); } const params = new URLSearchParams(); if (sessionId) params.set("sessionId", sessionId); if (key) params.set("key", key); + if (phone) params.set("phone", phone); try { const response = await fetch(`${OPENCLAW_API_URL}/loglife/sessions?${params}`, { diff --git a/website/app/api/verify/route.ts b/website/app/api/verify/route.ts new file mode 100644 index 00000000..0d8d1e5b --- /dev/null +++ b/website/app/api/verify/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; + +export async function POST(req: NextRequest) { + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { action?: string; phone?: string; code?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { action, phone, code } = body; + + if (!action || !phone) { + return NextResponse.json({ error: "Missing required fields: action, phone" }, { status: 400 }); + } + + if (action === "send") { + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/verify/send`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ phone }), + }); + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } + } + + if (action === "check") { + if (!code) { + return NextResponse.json({ error: "Missing required field: code" }, { status: 400 }); + } + + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/verify/check`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ phone, code }), + }); + + const data = await response.json(); + + if (data.verified) { + const normalized = "+" + phone.replace(/[^0-9]/g, ""); + const client = await clerkClient(); + const user = await client.users.getUser(userId); + await client.users.updateUser(userId, { + unsafeMetadata: { ...user.unsafeMetadata, whatsappPhone: normalized }, + }); + } + + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } + } + + return NextResponse.json({ error: "Invalid action. Use 'send' or 'check'" }, { status: 400 }); +} diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index 3f3357c8..cc217e0d 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useUser, useClerk } from "@clerk/nextjs"; import { useRouter } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; interface WhatsAppSession { sessionKey?: string; @@ -50,39 +50,37 @@ export default function DashboardPage() { const [session, setSession] = useState(null); const [sessionLoading, setSessionLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [selectedService, setSelectedService] = useState<"whatsapp" | "telegram">("whatsapp"); - const [phoneNumber, setPhoneNumber] = useState(""); - const [sendingMessage, setSendingMessage] = useState(false); - const [sendMessageFeedback, setSendMessageFeedback] = useState(null); const menuRef = useRef(null); - const whatsappSessionId = (user?.unsafeMetadata as Record | undefined)?.whatsappSessionId || ""; + const [phoneNumber, setPhoneNumber] = useState(""); + const [verifyStep, setVerifyStep] = useState<"phone" | "code">("phone"); + const [verifyCode, setVerifyCode] = useState(""); + const [verifyLoading, setVerifyLoading] = useState(false); + const [verifyFeedback, setVerifyFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; - const fetchSession = (isRefresh = false) => { - if (!whatsappSessionId) { + const fetchSession = useCallback((isRefresh = false) => { + if (!whatsappPhone) { setSession(null); setSessionLoading(false); return; } - if (isRefresh) setRefreshing(true); else setSessionLoading(true); - fetch(`/api/sessions?sessionId=${encodeURIComponent(whatsappSessionId)}`) + fetch(`/api/sessions?phone=${encodeURIComponent(whatsappPhone)}`) .then((res) => res.json()) .then((data) => { if (!data.error) setSession(data); else setSession(null); }) .catch(() => { setSession(null); }) .finally(() => { setSessionLoading(false); setRefreshing(false); }); - }; + }, [whatsappPhone]); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { fetchSession(); }, [whatsappSessionId]); + useEffect(() => { fetchSession(); }, [fetchSession]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setMenuOpen(false); - } + if (menuRef.current && !menuRef.current.contains(event.target as Node)) setMenuOpen(false); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); @@ -106,6 +104,55 @@ export default function DashboardPage() { router.push("/"); }; + const handleSendCode = async () => { + if (!phoneNumber.trim()) return; + setVerifyLoading(true); + setVerifyFeedback(null); + try { + const res = await fetch("/api/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "send", phone: phoneNumber }), + }); + const data = await res.json(); + if (res.ok && data.sent) { + setVerifyStep("code"); + setVerifyFeedback({ type: "success", text: "Code sent! Check your WhatsApp." }); + } else { + setVerifyFeedback({ type: "error", text: data.error || "Failed to send code" }); + } + } catch { + setVerifyFeedback({ type: "error", text: "Network error. Please try again." }); + } finally { + setVerifyLoading(false); + } + }; + + const handleVerifyCode = async () => { + if (!verifyCode.trim()) return; + setVerifyLoading(true); + setVerifyFeedback(null); + try { + const res = await fetch("/api/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "check", phone: phoneNumber, code: verifyCode.trim() }), + }); + const data = await res.json(); + if (res.ok && data.verified) { + setVerifyFeedback({ type: "success", text: "Verified! Loading your dashboard..." }); + await user.reload(); + fetchSession(); + } else { + setVerifyFeedback({ type: "error", text: data.error || "Invalid or expired code" }); + } + } catch { + setVerifyFeedback({ type: "error", text: "Network error. Please try again." }); + } finally { + setVerifyLoading(false); + } + }; + return (
@@ -192,178 +239,124 @@ export default function DashboardPage() {
) : !session ? (
- {/* Onboarding Header */}
-

Start logging now.

-

Connect your WhatsApp to begin journaling with LogLife.

+

Connect your WhatsApp

+

+ Enter your phone number to receive a verification code on WhatsApp and link your dashboard. +

- {/* Two-Column Cards */} -
- - {/* Left Card — "You message us" */} -
-
-
- - +
+
+
+
+ +
-

Message the AI

-

Start journaling right away

+

WhatsApp verification

+

+ {verifyStep === "phone" ? "Step 1 of 2 — enter your number" : "Step 2 of 2 — enter the code"} +

-
- {/* Service Selector */} -
- -
- - - - -
-
- - -
- - - -
-
- - Send "start" to +1 (555) 000-0000 - - - Tap to open WhatsApp automatically - -
- - - -
-
- -

- To connect your dashboard, go to{" "} - Account Settings{" "} - after your first message. -

-
- - {/* Right Card — "We message you" (Recommended) */} -
- {/* Recommended Badge */} -
- - Recommended - -
- -
-
- - - -
-
-

Get messaged by LogLife

-

Fastest way to get started

-
-
+
+ {verifyStep === "phone" ? ( + <> +
+ +
+
+ + +
+ { + setPhoneNumber(e.target.value.replace(/[^0-9]/g, "")); + setVerifyFeedback(null); + }} + className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600" + placeholder="1 555 123 4567" + autoFocus + /> +
+

Include your country code (e.g. 1 for US, 44 for UK)

+
-
- {/* Phone Number Input */} -
- -
-
- + +
+
+ + + +

+ We'll send a 6-digit code to verify you own this number. The code expires in 5 minutes. +

+
+ + ) : ( +
+ { - setPhoneNumber(e.target.value.replace(/[^0-9]/g, "")); - setSendMessageFeedback(null); + setVerifyCode(e.target.value.replace(/[^0-9]/g, "")); + setVerifyFeedback(null); }} - className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600" - placeholder="1 555 123 4567" + className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600 font-mono text-center text-lg tracking-[0.5em]" + placeholder="000000" + autoFocus /> -
-

Include your country code (e.g. 1 for US)

-
- - {/* Info box */} -
-
- - - -

- We'll send you a WhatsApp message and connect your dashboard automatically — no extra steps needed. +

+ Sent to +{phoneNumber}.{" "} +

-
+ )} - {sendMessageFeedback && ( -
- {sendMessageFeedback} + {verifyFeedback && ( +
+ {verifyFeedback.text}
)}
-
- - {/* Already have a session ID */} -

- Already have a session ID?{" "} - - Connect manually in Account Settings - -

) : ( <> @@ -415,7 +408,7 @@ export default function DashboardPage() {
-

OpenAI

+

AI provider

@@ -481,10 +474,6 @@ export default function DashboardPage() { {/* Session Details Grid */}
-
-

Session ID

-

{session?.sessionId || "N/A"}

-

Channel

@@ -500,6 +489,10 @@ export default function DashboardPage() {

Compactions

{session?.compactionCount ?? 0}

+
+

Model

+

{session?.model || "N/A"}

+
{/* Token Usage Bar */} @@ -638,11 +631,11 @@ export default function DashboardPage() {
-

WhatsApp account {session?.origin?.label || "Unknown"} linked

-

Account connected via linking code

+

WhatsApp number {whatsappPhone} verified

+

Account connected via phone verification

- Connected + Verified
From bf476d791f3f51d4296b00371ace16b3e951ddfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 10:21:09 -0800 Subject: [PATCH 22/32] add plugin unit tests --- .github/workflows/ci.yml | 24 +- plugin/index.test.ts | 480 ++++++++++++ plugin/index.ts | 10 +- plugin/package-lock.json | 1536 ++++++++++++++++++++++++++++++++++++++ plugin/package.json | 14 + plugin/tsconfig.json | 12 + plugin/vitest.config.ts | 7 + 7 files changed, 2077 insertions(+), 6 deletions(-) create mode 100644 plugin/index.test.ts create mode 100644 plugin/package-lock.json create mode 100644 plugin/tsconfig.json create mode 100644 plugin/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52a8db22..6596cbb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,4 +46,26 @@ jobs: working-directory: website run: pnpm run build env: - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} \ No newline at end of file + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + + plugin: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + working-directory: plugin + run: npm ci + + - name: Typecheck + working-directory: plugin + run: npm run typecheck + + - name: Run tests + working-directory: plugin + run: npm test \ No newline at end of file diff --git a/plugin/index.test.ts b/plugin/index.test.ts new file mode 100644 index 00000000..919269dd --- /dev/null +++ b/plugin/index.test.ts @@ -0,0 +1,480 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Readable, Writable } from "node:stream"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { normalizePhone, verifyApiKey, safeCompare, readBody } from "./index.js"; + +// --------------------------------------------------------------------------- +// Helpers to build mock HTTP req/res for handler tests +// --------------------------------------------------------------------------- + +function mockReq(opts: { + method?: string; + url?: string; + headers?: Record; + body?: unknown; +}): IncomingMessage { + const readable = new Readable(); + readable._read = () => {}; + Object.assign(readable, { + method: opts.method ?? "GET", + url: opts.url ?? "/", + headers: opts.headers ?? {}, + }); + if (opts.body !== undefined) { + const json = JSON.stringify(opts.body); + process.nextTick(() => { + readable.push(json); + readable.push(null); + }); + } else { + process.nextTick(() => readable.push(null)); + } + return readable as unknown as IncomingMessage; +} + +type MockRes = ServerResponse & { + _status: number; + _headers: Record; + _body: string; + json(): unknown; +}; + +function mockRes(): MockRes { + const chunks: Buffer[] = []; + const writable = new Writable({ + write(chunk, _enc, cb) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + cb(); + }, + }); + const res = Object.assign(writable, { + statusCode: 200, + _status: 200, + _headers: {} as Record, + _body: "", + setHeader(name: string, value: string) { + res._headers[name.toLowerCase()] = value; + }, + end(data?: string | Buffer) { + if (data) chunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)); + res._body = Buffer.concat(chunks).toString("utf-8"); + res._status = res.statusCode; + }, + json() { + return JSON.parse(res._body); + }, + }); + return res as unknown as MockRes; +} + +// --------------------------------------------------------------------------- +// Mock for the plugin API — captures registered handlers +// --------------------------------------------------------------------------- + +type RouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise; + +function createMockApi(config?: { apiKey?: string; agentId?: string }) { + const routes = new Map(); + return { + routes, + api: { + pluginConfig: config ?? {}, + config: { gateway: { port: 18789 } }, + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + registerHttpRoute: ({ path, handler }: { path: string; handler: RouteHandler }) => { + routes.set(path, handler); + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// normalizePhone +// --------------------------------------------------------------------------- + +describe("normalizePhone", () => { + it("strips non-digits and adds + prefix", () => { + expect(normalizePhone("+1 (555) 123-4567")).toBe("+15551234567"); + }); + + it("handles raw digits", () => { + expect(normalizePhone("15551234567")).toBe("+15551234567"); + }); + + it("handles already-normalized input", () => { + expect(normalizePhone("+15551234567")).toBe("+15551234567"); + }); + + it("handles international formats", () => { + expect(normalizePhone("+44 7911 123456")).toBe("+447911123456"); + }); + + it("handles empty string", () => { + expect(normalizePhone("")).toBe("+"); + }); +}); + +// --------------------------------------------------------------------------- +// verifyApiKey +// --------------------------------------------------------------------------- + +describe("verifyApiKey", () => { + const key = "test-api-key-12345"; + + it("returns true for valid bearer token", () => { + const req = mockReq({ headers: { authorization: `Bearer ${key}` } }); + expect(verifyApiKey(req, key)).toBe(true); + }); + + it("returns false for missing authorization header", () => { + const req = mockReq({ headers: {} }); + expect(verifyApiKey(req, key)).toBe(false); + }); + + it("returns false for wrong token", () => { + const req = mockReq({ headers: { authorization: "Bearer wrong-key-xxxxxxx" } }); + expect(verifyApiKey(req, key)).toBe(false); + }); + + it("returns false for non-bearer auth", () => { + const req = mockReq({ headers: { authorization: `Basic ${key}` } }); + expect(verifyApiKey(req, key)).toBe(false); + }); + + it("returns false for token with different length", () => { + const req = mockReq({ headers: { authorization: "Bearer short" } }); + expect(verifyApiKey(req, key)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// safeCompare +// --------------------------------------------------------------------------- + +describe("safeCompare", () => { + it("returns true for matching strings", () => { + expect(safeCompare("123456", "123456")).toBe(true); + }); + + it("returns false for different strings of same length", () => { + expect(safeCompare("123456", "654321")).toBe(false); + }); + + it("returns false for different lengths", () => { + expect(safeCompare("123", "123456")).toBe(false); + }); + + it("returns false for empty vs non-empty", () => { + expect(safeCompare("", "123456")).toBe(false); + }); + + it("returns true for empty vs empty", () => { + expect(safeCompare("", "")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// readBody +// --------------------------------------------------------------------------- + +describe("readBody", () => { + it("parses valid JSON body", async () => { + const req = mockReq({ body: { phone: "+15551234567" } }); + const result = await readBody(req); + expect(result).toEqual({ phone: "+15551234567" }); + }); + + it("rejects on invalid JSON", async () => { + const readable = new Readable(); + readable._read = () => {}; + Object.assign(readable, { method: "POST", url: "/", headers: {} }); + process.nextTick(() => { + readable.push("not json"); + readable.push(null); + }); + await expect(readBody(readable as unknown as IncomingMessage)).rejects.toThrow("Invalid JSON"); + }); +}); + +// --------------------------------------------------------------------------- +// Handler tests: GET /loglife/sessions +// --------------------------------------------------------------------------- + +describe("GET /loglife/sessions handler", () => { + const API_KEY = "test-key-abcdef"; + + const sessionsData = { + "whatsapp:15551234567@s.whatsapp.net": { + sessionId: "uuid-1234", + updatedAt: 1700000000000, + abortedLastRun: false, + chatType: "dm", + lastChannel: "whatsapp", + origin: { label: "Test User", from: "+15551234567", to: "+19999999999" }, + deliveryContext: { channel: "whatsapp", to: "+15551234567" }, + compactionCount: 2, + inputTokens: 500, + outputTokens: 300, + totalTokens: 800, + model: "gpt-4o", + }, + }; + + let handler: RouteHandler; + + beforeEach(async () => { + vi.doMock("node:fs/promises", () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(sessionsData)), + })); + const mod = await import("./index.js"); + const { routes, api } = createMockApi({ apiKey: API_KEY }); + mod.default.register(api as never); + handler = routes.get("/loglife/sessions")!; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("returns 401 without auth", async () => { + const req = mockReq({ method: "GET", url: "/loglife/sessions?phone=123" }); + const res = mockRes(); + await handler(req, res); + expect(res._status).toBe(401); + }); + + it("returns 405 for non-GET methods", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/sessions?phone=123", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await handler(req, res); + expect(res._status).toBe(405); + }); + + it("returns 400 when no query params provided", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/sessions", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await handler(req, res); + expect(res._status).toBe(400); + expect(res.json()).toEqual({ error: "Provide ?sessionId=, ?key=, or ?phone=" }); + }); + + it("finds session by phone number", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/sessions?phone=%2B15551234567", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await handler(req, res); + expect(res._status).toBe(200); + const body = res.json() as Record; + expect(body.sessionId).toBe("uuid-1234"); + expect((body.origin as Record).from).toBe("+15551234567"); + expect(body.model).toBe("gpt-4o"); + }); + + it("finds session by sessionId", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/sessions?sessionId=uuid-1234", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await handler(req, res); + expect(res._status).toBe(200); + expect((res.json() as Record).sessionId).toBe("uuid-1234"); + }); + + it("finds session by key", async () => { + const req = mockReq({ + method: "GET", + url: `/loglife/sessions?key=${encodeURIComponent("whatsapp:15551234567@s.whatsapp.net")}`, + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await handler(req, res); + expect(res._status).toBe(200); + expect((res.json() as Record).sessionId).toBe("uuid-1234"); + }); + + it("returns 404 for non-existent session", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/sessions?phone=%2B10000000000", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await handler(req, res); + expect(res._status).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// Handler tests: POST /loglife/verify/check +// --------------------------------------------------------------------------- + +describe("POST /loglife/verify/check handler", () => { + const API_KEY = "verify-test-key"; + + let checkHandler: RouteHandler; + let sendHandler: RouteHandler; + + beforeEach(async () => { + vi.resetModules(); + vi.doMock("ws", () => ({ + WebSocket: vi.fn(), + })); + const mod = await import("./index.js"); + const { routes, api } = createMockApi({ apiKey: API_KEY }); + mod.default.register(api as never); + checkHandler = routes.get("/loglife/verify/check")!; + sendHandler = routes.get("/loglife/verify/send")!; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("returns 401 without auth", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/verify/check", + body: { phone: "+15551234567", code: "123456" }, + }); + const res = mockRes(); + await checkHandler(req, res); + expect(res._status).toBe(401); + }); + + it("returns 400 when phone is missing", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/verify/check", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { code: "123456" }, + }); + const res = mockRes(); + await checkHandler(req, res); + expect(res._status).toBe(400); + expect(res.json()).toEqual({ error: "Missing required field: phone" }); + }); + + it("returns 400 when code is missing", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/verify/check", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { phone: "+15551234567" }, + }); + const res = mockRes(); + await checkHandler(req, res); + expect(res._status).toBe(400); + expect(res.json()).toEqual({ error: "Missing required field: code" }); + }); + + it("returns verified:false for code that was never sent", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/verify/check", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { phone: "+15551234567", code: "123456" }, + }); + const res = mockRes(); + await checkHandler(req, res); + expect(res._status).toBe(200); + const body = res.json() as Record; + expect(body.verified).toBe(false); + }); + + it("returns 405 for GET method", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/verify/check", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await checkHandler(req, res); + expect(res._status).toBe(405); + }); +}); + +// --------------------------------------------------------------------------- +// Handler tests: POST /loglife/verify/send +// --------------------------------------------------------------------------- + +describe("POST /loglife/verify/send handler", () => { + const API_KEY = "send-test-key-x"; + + let sendHandler: RouteHandler; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("./index.js"); + const { routes, api } = createMockApi({ apiKey: API_KEY }); + mod.default.register(api as never); + sendHandler = routes.get("/loglife/verify/send")!; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("returns 401 without auth", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/verify/send", + body: { phone: "+15551234567" }, + }); + const res = mockRes(); + await sendHandler(req, res); + expect(res._status).toBe(401); + }); + + it("returns 400 when phone is missing", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/verify/send", + headers: { authorization: `Bearer ${API_KEY}` }, + body: {}, + }); + const res = mockRes(); + await sendHandler(req, res); + expect(res._status).toBe(400); + expect(res.json()).toEqual({ error: "Missing required field: phone" }); + }); + + it("returns 400 for invalid (too short) phone number", async () => { + const req = mockReq({ + method: "POST", + url: "/loglife/verify/send", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { phone: "12" }, + }); + const res = mockRes(); + await sendHandler(req, res); + expect(res._status).toBe(400); + expect(res.json()).toEqual({ error: "Invalid phone number" }); + }); + + it("returns 405 for GET method", async () => { + const req = mockReq({ + method: "GET", + url: "/loglife/verify/send", + headers: { authorization: `Bearer ${API_KEY}` }, + }); + const res = mockRes(); + await sendHandler(req, res); + expect(res._status).toBe(405); + }); +}); diff --git a/plugin/index.ts b/plugin/index.ts index d6112951..28022a7a 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -22,12 +22,12 @@ const VERIFY_COOLDOWN_MS = 60 * 1000; const verificationCodes = new Map(); -function normalizePhone(raw: string): string { +export function normalizePhone(raw: string): string { const digits = raw.replace(/[^0-9]/g, ""); return "+" + digits; } -function verifyApiKey(req: IncomingMessage, expectedKey: string): boolean { +export function verifyApiKey(req: IncomingMessage, expectedKey: string): boolean { const auth = req.headers.authorization ?? ""; const prefix = "Bearer "; if (!auth.startsWith(prefix)) return false; @@ -40,7 +40,7 @@ function verifyApiKey(req: IncomingMessage, expectedKey: string): boolean { } } -function safeCompare(a: string, b: string): boolean { +export function safeCompare(a: string, b: string): boolean { if (a.length !== b.length) return false; try { return timingSafeEqual(Buffer.from(a), Buffer.from(b)); @@ -49,13 +49,13 @@ function safeCompare(a: string, b: string): boolean { } } -function jsonResponse(res: ServerResponse, status: number, body: unknown): void { +export function jsonResponse(res: ServerResponse, status: number, body: unknown): void { res.statusCode = status; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(body)); } -async function readBody(req: IncomingMessage): Promise> { +export async function readBody(req: IncomingMessage): Promise> { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); diff --git a/plugin/package-lock.json b/plugin/package-lock.json new file mode 100644 index 00000000..810a49e7 --- /dev/null +++ b/plugin/package-lock.json @@ -0,0 +1,1536 @@ +{ + "name": "loglife", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loglife", + "version": "0.1.0", + "dependencies": { + "ws": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.0.0", + "typescript": "^5.8.0", + "vitest": "^4.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/plugin/package.json b/plugin/package.json index 9c192b64..1c36dfbf 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -5,5 +5,19 @@ "type": "module", "openclaw": { "extensions": ["."] + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "vitest": "^4.0.0", + "typescript": "^5.8.0", + "@types/node": "^22.0.0", + "@types/ws": "^8.0.0" + }, + "dependencies": { + "ws": "^8.0.0" } } diff --git a/plugin/tsconfig.json b/plugin/tsconfig.json new file mode 100644 index 00000000..b564cd8d --- /dev/null +++ b/plugin/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/plugin/vitest.config.ts b/plugin/vitest.config.ts new file mode 100644 index 00000000..a31edba3 --- /dev/null +++ b/plugin/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["*.test.ts"], + }, +}); From 8966839d6feeeb4c07a2e3dca90d61904fdf9d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 10:54:26 -0800 Subject: [PATCH 23/32] add development and security docs --- docs/development.mdx | 4 ++ docs/docs.json | 1 + docs/security.mdx | 119 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 docs/security.mdx diff --git a/docs/development.mdx b/docs/development.mdx index 5e2e58f4..4365911c 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -5,6 +5,10 @@ description: 'Local development workflow for the LogLife website and plugin' ## Local setup + + LogLife is developed and tested on **Linux**. If you are on Windows, use **WSL**. macOS may work but is not fully tested — proceed at your own risk. + + **Prerequisites**: - Node.js 19+, pnpm diff --git a/docs/docs.json b/docs/docs.json index 60a8346a..1b4892aa 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -21,6 +21,7 @@ "self-hosting", "openclaw-tricks", "development", + "security", "contributing-docs" ] } diff --git a/docs/security.mdx b/docs/security.mdx new file mode 100644 index 00000000..fde86508 --- /dev/null +++ b/docs/security.mdx @@ -0,0 +1,119 @@ +--- +title: 'Security' +description: 'How LogLife protects your data and prevents abuse' +--- + +## Design principles + +LogLife follows a **server-initiated contact** model. Users must sign up through the website and verify their phone number before LogLife ever sends them a message. This is a deliberate architectural choice. + +### Why "we message first" + +| Concern | Open inbound (anyone texts us) | Server-initiated (we text first) | +|---|---|---| +| Identity | Unknown — anyone with the number can text | Known — tied to a Clerk account | +| API cost exposure | Unbounded — each message triggers LLM calls | Bounded — only verified users generate cost | +| Abuse surface | Wide — spam, prompt injection from strangers | Narrow — only authenticated users interact | +| Kill switch | Block phone numbers manually | Disable Clerk account, stop responding | +| Rate limiting | Hard — no user identity to rate-limit against | Easy — per-user, per-account limits | + +By requiring signup and phone verification, every interaction is tied to a known user. If someone abuses the system, their account can be disabled instantly. + +### Referrals instead of open access + +Instead of letting anyone message LogLife directly, new users are onboarded through: + +1. **Direct signup** at loglife.co/signup +2. **Referral links** shared by existing users + +Both paths go through Clerk authentication and phone verification before any messages are exchanged. This keeps the funnel controlled and auditable. + +## Authentication + +### Website to plugin + +All communication between the Next.js website (hosted on Vercel) and the OpenClaw plugin is authenticated with a **Bearer token**. The token is set as `OPENCLAW_API_KEY` on both sides. + +- The website's API routes (`/api/sessions`, `/api/verify`) add the token to every request to the plugin. +- The plugin validates the token using `crypto.timingSafeEqual` to prevent timing attacks. +- Requests without a valid token receive a `401 Unauthorized` response. + +### User authentication + +User authentication is handled by **Clerk**. The website's API routes verify that the caller has a valid Clerk session before proxying to the plugin. This means: + +- An unauthenticated browser request to `/api/verify` is rejected before it ever reaches the plugin. +- The plugin itself does not need to know about individual users — it trusts the website's Bearer token. + +## Phone verification + +Phone ownership is proven through a **6-digit verification code** sent via WhatsApp: + +1. User enters their phone number on the dashboard. +2. The plugin generates a code using `crypto.randomInt` (cryptographically secure). +3. The code is sent to the phone via the OpenClaw gateway. +4. The user enters the code on the dashboard. +5. The plugin compares it using `crypto.timingSafeEqual`. + +### Protections + +- **5-minute TTL**: Codes expire after 5 minutes. Expired codes are rejected. +- **Single use**: A code is deleted immediately after successful verification. It cannot be reused. +- **Rate limiting**: Only one code can be sent per phone number per 60 seconds. Repeated requests return `429 Too Many Requests`. +- **Timing-safe comparison**: Code comparison uses constant-time equality to prevent timing side-channel attacks. + +## Rate limits + +### Current limits + +| Resource | Limit | Window | +|---|---|---| +| Verification code sends | 1 per phone | 60 seconds | +| Verification code validity | 1 code | 5 minutes | + +### Planned limits + +As LogLife scales, additional guardrails will be added: + +- **Message rate limits** — maximum messages per user per day +- **Audio processing limits** — maximum audio messages and duration per day +- **Token budgets** — per-user token consumption caps per billing period +- **Usage dashboard** — visible on the dashboard so users can monitor their own consumption + +## Data flow + +```mermaid +%%{init: {'theme': 'neutral'}}%% +sequenceDiagram + participant U as User Browser + participant C as Clerk + participant W as Website API + participant P as Plugin + participant G as Gateway + participant WA as WhatsApp + + U->>C: Sign up / sign in + C-->>U: Session token + U->>W: POST /api/verify (send) + W->>C: Verify session + C-->>W: Valid user + W->>P: POST /loglife/verify/send (Bearer token) + P->>G: Send code via WebSocket + G->>WA: WhatsApp message with code + WA-->>U: Code on phone + U->>W: POST /api/verify (check) + W->>P: POST /loglife/verify/check (Bearer token) + P-->>W: verified: true + W->>C: Update user metadata + W-->>U: Dashboard connected + P->>G: Send welcome message + G->>WA: Welcome message +``` + +## Infrastructure security + +- **No secrets in code**: API keys, Clerk keys, and SSH keys are stored in GitHub Actions secrets and Vercel environment variables. Never committed to the repository. +- **SSH deployment**: Plugin deployment uses SSH key authentication. No passwords are transmitted. +- **Health checks**: Every deployment verifies the plugin is responding correctly before marking the deploy as successful. +- **Sessions persisted to disk**: Session data survives gateway restarts. No data loss during deployments. +- **In-memory verification codes**: Codes are intentionally not persisted. A gateway restart clears all pending codes, which is acceptable given their 5-minute TTL. From c034cc63276938f2a90299fbd595aa6cbc10b5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 10:55:25 -0800 Subject: [PATCH 24/32] add test for api/verification and welcome message --- plugin/index.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++--- plugin/index.ts | 11 +++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/plugin/index.test.ts b/plugin/index.test.ts index 919269dd..fd769bb0 100644 --- a/plugin/index.test.ts +++ b/plugin/index.test.ts @@ -326,18 +326,27 @@ describe("POST /loglife/verify/check handler", () => { const API_KEY = "verify-test-key"; let checkHandler: RouteHandler; - let sendHandler: RouteHandler; + const wsSent: string[] = []; beforeEach(async () => { vi.resetModules(); + wsSent.length = 0; vi.doMock("ws", () => ({ - WebSocket: vi.fn(), + WebSocket: function MockWebSocket() { + const handlers: Record void> = {}; + const ws = { + on(event: string, fn: (...args: unknown[]) => void) { handlers[event] = fn; return ws; }, + send(data: string) { wsSent.push(data); }, + close() {}, + }; + setTimeout(() => handlers.open?.(), 0); + return ws; + }, })); const mod = await import("./index.js"); const { routes, api } = createMockApi({ apiKey: API_KEY }); mod.default.register(api as never); checkHandler = routes.get("/loglife/verify/check")!; - sendHandler = routes.get("/loglife/verify/send")!; }); afterEach(() => { @@ -382,6 +391,37 @@ describe("POST /loglife/verify/check handler", () => { expect(res.json()).toEqual({ error: "Missing required field: code" }); }); + it("returns verified:true and triggers welcome message for valid code", async () => { + const { verificationCodes } = await import("./index.js"); + const phone = "+15559999999"; + verificationCodes.set(phone, { + code: "123456", + expiresAt: Date.now() + 300_000, + sentAt: Date.now() - 10_000, + }); + + const req = mockReq({ + method: "POST", + url: "/loglife/verify/check", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { phone, code: "123456" }, + }); + const res = mockRes(); + await checkHandler(req, res); + + expect(res._status).toBe(200); + expect((res.json() as Record).verified).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + const welcomePayload = wsSent.find((m) => { + try { + const p = JSON.parse(m); + return p.method === "send" && p.params?.message?.includes("Welcome"); + } catch { return false; } + }); + expect(welcomePayload).toBeDefined(); + }); + it("returns verified:false for code that was never sent", async () => { const req = mockReq({ method: "POST", diff --git a/plugin/index.ts b/plugin/index.ts index 28022a7a..1514b350 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -11,7 +11,7 @@ type LogLifeConfig = { agentId?: string; }; -type VerificationEntry = { +export type VerificationEntry = { code: string; expiresAt: number; sentAt: number; @@ -20,7 +20,7 @@ type VerificationEntry = { const VERIFY_TTL_MS = 5 * 60 * 1000; const VERIFY_COOLDOWN_MS = 60 * 1000; -const verificationCodes = new Map(); +export const verificationCodes = new Map(); export function normalizePhone(raw: string): string { const digits = raw.replace(/[^0-9]/g, ""); @@ -364,6 +364,13 @@ const plugin = { verificationCodes.delete(phone); jsonResponse(res, 200, { verified: true }); + + sendViaGateway( + gatewayPort, + gatewayToken, + phone, + "Welcome to LogLife! Your dashboard is now connected. Send me a message anytime to start journaling.", + ).catch(() => { /* best-effort — don't fail verification if welcome message fails */ }); }, }); }, From fe0ae5dd21c97ad7f2e610e675ce7e118a8db925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 10:55:44 -0800 Subject: [PATCH 25/32] improve dashboard ux - enable ENTER to send msg --- website/app/dashboard/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index cc217e0d..1ec0d761 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -278,6 +278,7 @@ export default function DashboardPage() { setPhoneNumber(e.target.value.replace(/[^0-9]/g, "")); setVerifyFeedback(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && phoneNumber.trim()) handleSendCode(); }} className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600" placeholder="1 555 123 4567" autoFocus @@ -309,6 +310,7 @@ export default function DashboardPage() { setVerifyCode(e.target.value.replace(/[^0-9]/g, "")); setVerifyFeedback(null); }} + onKeyDown={(e) => { if (e.key === "Enter" && verifyCode.length === 6) handleVerifyCode(); }} className="w-full rounded-lg bg-slate-950/50 border border-slate-700/50 text-white text-sm px-3 py-2.5 focus:outline-none focus:ring-1 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all placeholder-slate-600 font-mono text-center text-lg tracking-[0.5em]" placeholder="000000" autoFocus From 871f5e675b83d8bee79010c183883f90bab8dbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 11:10:03 -0800 Subject: [PATCH 26/32] update development documentation to include requirement for two phone numbers for OpenClaw bot functionality --- docs/development.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/development.mdx b/docs/development.mdx index 4365911c..97cd6848 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -13,6 +13,7 @@ description: 'Local development workflow for the LogLife website and plugin' **Prerequisites**: - Node.js 19+, pnpm - OpenClaw installed (see [Self-hosting](/self-hosting)) + - **Two phone numbers**: one for the OpenClaw bot (its WhatsApp account) and one to message from (your personal phone). You cannot send messages to yourself on WhatsApp. From 164aa4762f849787d8a99b7156f7399b6540f72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Morais?= Date: Sun, 22 Feb 2026 11:43:01 -0800 Subject: [PATCH 27/32] refactor sidebar links to direct to home page instead of conditional dashboard or hero section --- website/app/components/Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/app/components/Sidebar.tsx b/website/app/components/Sidebar.tsx index 13701be6..512db17e 100644 --- a/website/app/components/Sidebar.tsx +++ b/website/app/components/Sidebar.tsx @@ -136,7 +136,7 @@ export default function Sidebar() { {/* Top Navigation Bar - visible below lg */}