diff --git a/docs/ai-tools/cursor.mdx b/.cursor/rules.md similarity index 94% rename from docs/ai-tools/cursor.mdx rename to .cursor/rules.md index fbb77616..7d985262 100644 --- a/docs/ai-tools/cursor.mdx +++ b/.cursor/rules.md @@ -1,27 +1,3 @@ ---- -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. @@ -416,5 +392,4 @@ description: "Concise description explaining page purpose and value" - 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 -```` +- Use **Expandable** for nested object properties or hierarchical information \ No newline at end of file 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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 15d0d4d3..6b2dada0 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,50 @@ jobs: runs-on: ubuntu-latest steps: - - name: Deploy to server + - name: Deploy plugin and restart gateway 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" + cd ~/loglife + git fetch origin + git checkout main + git reset --hard origin/main - DEPLOY_DIR="$HOME/loglife-preview" - APP_NAME=loglife-preview - PORT=3001 - BRANCH=${{ github.head_ref }} + # Restart the gateway so it loads the updated plugin code + openclaw gateway restart - if [ ! -d "$DEPLOY_DIR" ]; then - git clone https://github.com/${{ github.repository }}.git "$DEPLOY_DIR" - fi + # Wait for gateway to come back up + sleep 5 - cd "$DEPLOY_DIR" - git fetch origin - git checkout -- . - git clean -fd - git checkout "$BRANCH" - git reset --hard "origin/$BRANCH" + # 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") - cd website - pnpm install --frozen-lockfile - pnpm run build + 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") - NODE_BIN=$(which node) - NEXT_BIN="$DEPLOY_DIR/website/node_modules/.bin/next" + echo "Plugin health check: sessions=$SESSIONS_STATUS verify=$VERIFY_STATUS" - 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" + # 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 - systemctl --user daemon-reload - systemctl --user enable "$APP_NAME" - systemctl --user restart "$APP_NAME" + echo "All health checks passed." 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/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/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/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/development.mdx b/docs/development.mdx index ac633bad..037b3272 100644 --- a/docs/development.mdx +++ b/docs/development.mdx @@ -1,94 +1,118 @@ --- title: 'Development' -description: 'Preview changes locally to update your docs' +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 version 19 or higher - - A docs repository with a `docs.json` file + - 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. -Follow these steps to install and run Mintlify on your operating system. - - + ```bash -npm i -g mint +cd ~/openclaw +./openclaw.mjs gateway --allow-unconfigured ``` - - +The gateway runs on port 18789 by default. The LogLife plugin is loaded automatically if installed (see [Self-hosting](/self-hosting) for plugin installation). + -Navigate to your docs directory where your `docs.json` file is located, and run the following command: + ```bash -mint dev +cd loglife/website +pnpm install +pnpm dev ``` -A local preview of your documentation will be available at `http://localhost:3000`. +The dashboard is at `http://localhost:3000/dashboard`. + + + +When you change `plugin/index.ts`, restart the local gateway to pick up the changes. The website hot-reloads automatically. -## Custom ports +## CI/CD -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: +LogLife uses two CI/CD pipelines: GitHub Actions for testing and plugin deployment, and Vercel for the website. -```bash -mint dev --port 3333 -``` +### GitHub Actions secrets -If you attempt to run Mintlify on a port that's already in use, it will use the next available port: +If you fork this repository, you need to add the following secrets in **Settings > Secrets and variables > Actions**: -```md -Port 3000 is already in use. Trying 3001 instead. -``` +| Secret | Used by | Description | +|--------|---------|-------------| +| `SERVER_HOST` | `deploy.yml` | Public IP or hostname of the production server | +| `SERVER_USER` | `deploy.yml` | SSH username on the production server | +| `SSH_PRIVATE_KEY` | `deploy.yml` | Private SSH key authorized on the server (see below) | +| `LOGLIFE_API_KEY` | `deploy.yml` | LogLife plugin API key — used for post-deploy health checks | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | `ci.yml` | Clerk publishable key — needed for the website build step | -## Mintlify versions + + Generate a dedicated deploy key instead of using your personal SSH key: -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: + ```bash + ssh-keygen -t ed25519 -f ~/.ssh/loglife_deploy -N "" -C "loglife-deploy" + cat ~/.ssh/loglife_deploy.pub >> ~/.ssh/authorized_keys + cat ~/.ssh/loglife_deploy # paste this as SSH_PRIVATE_KEY + ``` + -```bash -npm mint update -``` +### Vercel environment variables -## Validating links +Set these in your [Vercel project settings](https://vercel.com/docs/environment-variables): -The CLI can assist with validating links in your documentation. To identify any broken links, use the following command: +| Variable | Description | +|----------|-------------| +| `OPENCLAW_API_URL` | Production API URL (e.g. `https://api.loglife.co`) | +| `OPENCLAW_API_KEY` | Same key as `LOGLIFE_API_KEY` above | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk publishable key | +| `CLERK_SECRET_KEY` | Clerk secret key (server-side only) | -```bash -mint broken-links -``` +### Plugin deployment + +When plugin changes are pushed to `main`, the deploy workflow (`.github/workflows/deploy.yml`): + +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 -## Deployment +**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). -If the deployment is successful, you should see the following: +### Health checks - - Screenshot of a deployment confirmation message that says All checks have passed. - +The deploy workflow verifies two things after each restart: -## Code formatting +- `GET /loglife/sessions?phone=healthcheck` returns 404 (plugin loaded, searched, found nothing) +- `POST /loglife/verify/check` with dummy data returns 200 (verify endpoint loaded) -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. +If either check fails, the deploy is marked as failed in GitHub Actions. -## Troubleshooting +### Website deployment - - +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`. - 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` - +## Docs preview - - - Solution: Go to the root of your device and delete the `~/.mintlify` folder. Then run `mint dev` again. - - +To preview documentation changes locally: + +```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 46b44cc4..c380bfee 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", @@ -18,31 +18,12 @@ "pages": [ "index", "quickstart", - "development" - ] - }, - { - "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" + "self-hosting", + "openclaw-tricks", + "development", + "security", + "networking", + "contributing-docs" ] } ] @@ -57,12 +38,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" ] } ] @@ -71,13 +51,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" } ] @@ -85,19 +70,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": { @@ -112,11 +98,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/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/networking.mdx b/docs/networking.mdx new file mode 100644 index 00000000..ed742051 --- /dev/null +++ b/docs/networking.mdx @@ -0,0 +1,285 @@ +--- +title: 'Networking' +description: 'Reverse proxy, SSL, and domain setup for production deployments' +--- + +In development, the OpenClaw gateway listens on `localhost:18789` and the website connects to it directly. In production, you need HTTPS and proper domain names. This guide sets up [Caddy](https://caddyserver.com) as a reverse proxy with automatic SSL certificates. + +## Architecture + +```mermaid +%%{init: {'theme': 'neutral'}}%% +flowchart LR + vercel["Vercel\nNext.js app"] -->|"HTTPS"| apiDomain["api.loglife.co\n:443"] + browser["Browser"] -->|"HTTPS + basic auth"| adminDomain["admin.loglife.co\n:443"] + + subgraph server ["Your server"] + caddy["Caddy\nport 443"] + gw["OpenClaw gateway\nlocalhost:18789"] + apiDomain --> caddy + adminDomain --> caddy + caddy -->|"reverse proxy"| gw + end +``` + +Both domains proxy to the same OpenClaw gateway on `localhost:18789`. The gateway serves the LogLife plugin API (`/loglife/*`) and the OpenClaw Control UI (`/__openclaw__/`) on the same port. Caddy handles SSL, domain routing, and access control. + +### Why a reverse proxy? + +- **HTTPS**: Without it, API keys and gateway tokens travel in plain text. Caddy gets free SSL certificates from Let's Encrypt automatically. +- **Access control**: The API domain is open (protected by Bearer token). The admin domain adds HTTP basic auth so the Control UI is not publicly accessible. +- **Clean URLs**: Users and Vercel connect to `https://api.loglife.co` instead of `http://123.45.67.89:18789`. +- **Port isolation**: Port 18789 stays closed to the public. Only Caddy reaches it internally. + +## DNS setup + +Add two A records in your DNS provider (e.g. GoDaddy, Cloudflare) pointing to your server's IP: + +| Type | Name | Value | TTL | +|------|------|-------|-----| +| A | `api` | Your server IP | 600 | +| A | `admin` | Your server IP | 600 | + +Both records point to the same IP. Caddy differentiates them by domain name. + + + DNS changes can take up to an hour to propagate, but typically complete in under 5 minutes. + + +## Caddy setup + +### Install Caddy + +```bash +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install caddy +``` + +### Generate a password hash + +Pick a username and password for the Control UI. Caddy uses bcrypt hashes: + +```bash +caddy hash-password --plaintext 'your-password-here' +``` + +Copy the output (starts with `$2a$14$...`). + +### Configure Caddy + +Edit `/etc/caddy/Caddyfile`: + +```text +api.loglife.co { + reverse_proxy localhost:18789 +} + +admin.loglife.co { + basicauth { + admin $2a$14$PASTE_YOUR_HASH_HERE + } + reverse_proxy localhost:18789 +} +``` + +Replace `admin` with your chosen username and paste the hash from the previous step. + +### Gateway token + +The Control UI has its own authentication layer (separate from Caddy's basic auth). After passing basic auth, the Control UI will prompt for the **gateway token**. The gateway auto-generates this token on first startup and saves it to `~/.openclaw/openclaw.json`. Read it with: + +```bash +./openclaw.mjs config get gateway.auth.token +``` + +If you need to regenerate it (e.g. after a suspected leak): + +```bash +./openclaw.mjs config set gateway.auth.token "$(openssl rand -hex 24)" +./openclaw.mjs gateway restart +``` + + + The gateway token, the Caddy basic auth password, and the LogLife API key are three separate credentials: + + - **Gateway token** (`gateway.auth.token`) — authenticates the Control UI and CLI connections to the gateway + - **Caddy basic auth** — protects `admin.loglife.co` from public access + - **LogLife API key** (`plugins.entries.loglife.config.apiKey`) — authenticates Vercel's requests to the plugin endpoints + + +### Start Caddy + +```bash +sudo systemctl restart caddy +sudo systemctl enable caddy +``` + +Caddy automatically obtains SSL certificates. No additional configuration needed. + +### Firewall + +Open ports 80 and 443, and close direct access to the gateway port: + +```bash +sudo ufw allow 80/tcp # Let's Encrypt ACME challenge +sudo ufw allow 443/tcp # HTTPS +sudo ufw delete allow 18789/tcp # close direct access +``` + + + **Hetzner Cloud users**: Hetzner has its own firewall layer that runs *above* `ufw`. Even if `ufw` allows a port, Hetzner's firewall can still block it. Go to the [Hetzner Cloud Console](https://console.hetzner.cloud) > your project > **Firewalls** and add inbound rules for **TCP 80** and **TCP 443** from `0.0.0.0/0` and `::/0`. Without this, Caddy cannot complete the ACME challenge and SSL certificates will fail with "Timeout during connect (likely firewall problem)". + + + + Other cloud providers (AWS, GCP, DigitalOcean, etc.) have similar external firewalls — security groups, VPC firewall rules, or cloud firewalls. Always check both the OS-level firewall (`ufw`, `iptables`) and the cloud provider's firewall when ports appear closed. + + +### Verify + +```bash +# API endpoint (should return 404 — "Session not found") +curl -H "Authorization: Bearer YOUR_API_KEY" \ + "https://api.loglife.co/loglife/sessions?phone=healthcheck" + +# Admin UI (should prompt for username/password in browser) +curl -I "https://admin.loglife.co/__openclaw__/" +# Expected: 401 Unauthorized (basic auth required) +``` + +## Update Vercel + +After Caddy is running, update the `OPENCLAW_API_URL` in your Vercel project settings: + +| Variable | Old value | New value | +|----------|-----------|-----------| +| `OPENCLAW_API_URL` | `http://SERVER_IP:18789` | `https://api.loglife.co` | + +The `OPENCLAW_API_KEY` stays the same. + +## Troubleshooting + +### DNS + + + + + Check your DNS records: + + ```bash + dig api.loglife.co +short + dig admin.loglife.co +short + ``` + + Both should return your server's IP. If not, verify the A records in your DNS provider (GoDaddy, Cloudflare, etc.). DNS changes can take up to an hour to propagate — wait and retry. + + + + DNS is fine, but the server isn't accepting connections. Check that Caddy is running and ports are open: + + ```bash + sudo systemctl status caddy + sudo ss -tlnp | grep -E ':80|:443' + ``` + + If nothing is listening on 80/443, restart Caddy: `sudo systemctl restart caddy`. + + + + +### SSL certificates + + + + + This means Let's Encrypt cannot reach your server on port 80 or 443 to verify domain ownership. Most common causes: + + 1. **Cloud provider firewall** (Hetzner, AWS, GCP, etc.) is blocking inbound traffic — see the warning above about Hetzner Cloud Firewalls. + 2. **OS firewall** (`ufw` or `iptables`) is blocking port 80. Check with `sudo ufw status` or `sudo iptables -L -n`. + 3. **Another service** is already using port 80 (e.g. Apache). Check with `sudo ss -tlnp | grep :80`. + + After fixing the firewall, restart Caddy so it retries: + + ```bash + sudo systemctl restart caddy + sleep 15 + sudo journalctl -u caddy --no-pager -n 10 + ``` + + Look for "certificate obtained successfully" in the logs. + + + + If `curl https://api.loglife.co` fails with an SSL error from the server, it may be because the certificate hasn't been issued yet. Check Caddy logs: + + ```bash + sudo journalctl -u caddy --no-pager -n 20 + ``` + + If certificates are still being obtained, wait a moment and retry. Caddy will retry automatically on failure. + + + + +### Reverse proxy + + + + + The OpenClaw gateway is not running. Start it: + + ```bash + cd ~/openclaw + ./openclaw.mjs gateway start + ``` + + Verify it's listening: + + ```bash + curl http://localhost:18789 + ``` + + + + The Bearer token doesn't match. Check that the key in Vercel matches the key in OpenClaw's config: + + ```bash + grep apiKey ~/.openclaw/openclaw.json + ``` + + Update whichever side is out of sync. If you regenerated the key, update both Vercel and GitHub secrets. + + + + The OpenClaw gateway may also require its own authentication token. You'll see the basic auth prompt from Caddy first, then the gateway may require its own credentials. Check your gateway config: + + ```bash + grep -A2 '"auth"' ~/.openclaw/openclaw.json + ``` + + The gateway token is separate from the Caddy basic auth password and from the LogLife API key. + + + + The issue is between Vercel's servers and your server. Check step by step: + + ```bash + # 1. Verify DNS resolves correctly + dig api.loglife.co +short + + # 2. Verify HTTPS works externally + curl -I https://api.loglife.co + + # 3. Verify the plugin responds + curl -H "Authorization: Bearer YOUR_KEY" \ + "https://api.loglife.co/loglife/sessions?phone=test" + ``` + + If step 2 times out, ports 80/443 are blocked at the cloud provider level. If step 3 returns 401, the API key is wrong. + + + 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`. + diff --git a/docs/security.mdx b/docs/security.mdx new file mode 100644 index 00000000..4ad17804 --- /dev/null +++ b/docs/security.mdx @@ -0,0 +1,121 @@ +--- +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 runtime API + 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. +- **HTTPS everywhere**: A Caddy reverse proxy terminates SSL in front of the gateway. The gateway port (18789) is closed to the public — only Caddy reaches it locally. See the [Networking guide](/networking) for setup. +- **Layered auth on Control UI**: The OpenClaw Control UI is protected by HTTP basic auth (Caddy) in addition to the gateway's own authentication token. The API domain is open but protected by the Bearer token. +- **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. diff --git a/docs/self-hosting.mdx b/docs/self-hosting.mdx new file mode 100644 index 00000000..ad0c1125 --- /dev/null +++ b/docs/self-hosting.mdx @@ -0,0 +1,221 @@ +--- +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. + + + + + +## Automated setup + +If you prefer a single script instead of manual steps, the plugin includes a production setup script that handles steps 3–5 automatically: + +```bash +bash ~/loglife/plugin/setup.sh +``` + +The script installs the plugin, generates an API key (if not already set), restarts the gateway, and runs a health check. It prints the API key and next steps at the end. + +## Production deployment + +### Networking + +In production, you should put a reverse proxy (Caddy) in front of the gateway for HTTPS and access control. See the [Networking guide](/networking) for full setup instructions including DNS, SSL, and basic auth for the Control UI. + +### 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` | `https://api.yourdomain.com` (see [Networking](/networking)) | +| `OPENCLAW_API_KEY` | The key from step 4 above | + +### 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. You need to configure GitHub Actions secrets and Vercel environment variables — see the [CI/CD section](/development#ci-cd) for the full list of required secrets. + +## 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 + ``` + + + diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 00000000..9185f324 --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,120 @@ +# LogLife Plugin for OpenClaw + +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. + +## 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 +cd ~/openclaw +./openclaw.mjs plugins install /path/to/loglife/plugin --link +``` + +This registers the plugin in your OpenClaw config and loads it directly from the repo (no copy). Updates arrive via `git pull`. + +### 3. Set the API key + +Generate a key and store it in your OpenClaw config: + +```bash +cd ~/openclaw +./openclaw.mjs config set plugins.entries.loglife.config.apiKey "$(openssl rand -hex 32)" +``` + +Note the key — you'll need it for the website in step 5. + +To see the current key: + +```bash +cat ~/.openclaw/openclaw.json | grep apiKey +``` + +### 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 +``` + +Verify the plugin loaded by testing 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"}`). + +### 5. Set up the website + +```bash +cd loglife/website +pnpm install +``` + +Add the OpenClaw connection to your `.env`: + +``` +OPENCLAW_API_URL=http://localhost:18789 +OPENCLAW_API_KEY= +``` + +Start the dev server: + +```bash +pnpm dev +``` + +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.test.ts b/plugin/index.test.ts new file mode 100644 index 00000000..87de6aa3 --- /dev/null +++ b/plugin/index.test.ts @@ -0,0 +1,515 @@ +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(); + const mockSendWhatsApp = vi.fn().mockResolvedValue({ messageId: "mock-id", toJid: "mock-jid" }); + return { + routes, + mockSendWhatsApp, + api: { + pluginConfig: config ?? {}, + config: {}, + runtime: { + channel: { + whatsapp: { + sendMessageWhatsApp: mockSendWhatsApp, + }, + }, + }, + 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 mockSendWA: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("./index.js"); + const mock = createMockApi({ apiKey: API_KEY }); + mockSendWA = mock.mockSendWhatsApp; + mod.default.register(mock.api as never); + checkHandler = mock.routes.get("/loglife/verify/check")!; + }); + + 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: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)); + expect(mockSendWA).toHaveBeenCalledWith( + phone, + expect.stringContaining("Welcome to LogLife"), + { verbose: false }, + ); + }); + + 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 new file mode 100644 index 00000000..759a4e62 --- /dev/null +++ b/plugin/index.ts @@ -0,0 +1,335 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { timingSafeEqual, randomInt } from "node:crypto"; +import { URL } from "node:url"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +type LogLifeConfig = { + apiKey: string; + agentId?: string; +}; + +export type VerificationEntry = { + code: string; + expiresAt: number; + sentAt: number; +}; + +const VERIFY_TTL_MS = 5 * 60 * 1000; +const VERIFY_COOLDOWN_MS = 60 * 1000; + +export const verificationCodes = new Map(); + +export function normalizePhone(raw: string): string { + const digits = raw.replace(/[^0-9]/g, ""); + return "+" + digits; +} + +export 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; + } +} + +export 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; + } +} + +export function jsonResponse(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +export 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); + }); +} + +type SendWhatsApp = ( + to: string, + body: string, + options: { verbose: boolean }, +) => Promise<{ messageId: string; toJid: string }>; + +async function sendWhatsAppMessage( + sendFn: SendWhatsApp, + to: string, + message: string, +): Promise<{ ok: boolean; error?: string }> { + try { + await sendFn(to, message, { verbose: false }); + return { ok: true }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return { ok: false, error: errMsg }; + } +} + +const plugin = { + id: "loglife", + name: "LogLife", + description: "Exposes session data over HTTP for the LogLife dashboard", + configSchema: { + type: "object" as const, + additionalProperties: false, + 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 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 sendWA = api.runtime.channel.whatsapp.sendMessageWhatsApp as SendWhatsApp; + + // --- GET /loglife/sessions --- + + 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"); + const phone = url.searchParams.get("phone"); + + if (!sessionId && !key && !phone) { + jsonResponse(res, 400, { error: "Provide ?sessionId=, ?key=, or ?phone=" }); + 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; + } + } + } 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) { + 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" }); + } + }, + }); + + // --- 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 sendWhatsAppMessage(sendWA, 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 }); + + sendWhatsAppMessage( + sendWA, + phone, + "Welcome to LogLife! Your dashboard is now connected. Send me a message anytime to start journaling.", + ).catch(() => { /* best-effort */ }); + }, + }); + }, +}; + +export default plugin; 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" + } + } + } + } +} diff --git a/plugin/openclaw-plugin-sdk.d.ts b/plugin/openclaw-plugin-sdk.d.ts new file mode 100644 index 00000000..7a1c380d --- /dev/null +++ b/plugin/openclaw-plugin-sdk.d.ts @@ -0,0 +1,40 @@ +/** + * Stub type declarations for openclaw/plugin-sdk. + * The real module is provided by the OpenClaw installation at runtime. + * This file allows TypeScript to compile in CI without OpenClaw present. + */ +declare module "openclaw/plugin-sdk" { + export interface OpenClawPluginApi { + id: string; + name: string; + version?: string; + description?: string; + source: string; + config: Record; + pluginConfig?: Record; + runtime: { + channel: { + whatsapp: { + sendMessageWhatsApp: ( + to: string, + body: string, + options: { verbose: boolean }, + ) => Promise<{ messageId: string; toJid: string }>; + }; + }; + }; + logger: { + debug?: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; + }; + registerHttpRoute: (params: { + path: string; + handler: ( + req: import("node:http").IncomingMessage, + res: import("node:http").ServerResponse, + ) => Promise; + }) => void; + } +} diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json new file mode 100644 index 00000000..ad13a431 --- /dev/null +++ b/plugin/openclaw.plugin.json @@ -0,0 +1,20 @@ +{ + "id": "loglife", + "name": "LogLife", + "description": "Exposes session data over HTTP for the LogLife dashboard", + "configSchema": { + "type": "object", + "additionalProperties": false, + "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-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 new file mode 100644 index 00000000..ed2f3a4d --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,19 @@ +{ + "name": "loglife", + "version": "0.1.0", + "description": "LogLife dashboard API plugin for OpenClaw", + "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" + } +} diff --git a/plugin/setup.sh b/plugin/setup.sh new file mode 100755 index 00000000..0706acda --- /dev/null +++ b/plugin/setup.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# LogLife setup script +# Run this on a server where OpenClaw is already built and installed. +# Usage: bash setup.sh [--loglife-dir DIR] [--openclaw-dir DIR] + +LOGLIFE_DIR="${LOGLIFE_DIR:-$HOME/loglife}" +OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/openclaw}" +OPENCLAW_BIN="$OPENCLAW_DIR/openclaw.mjs" + +while [[ $# -gt 0 ]]; do + case $1 in + --loglife-dir) LOGLIFE_DIR="$2"; shift 2 ;; + --openclaw-dir) OPENCLAW_DIR="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +echo "=== LogLife Production Setup ===" +echo " LogLife dir: $LOGLIFE_DIR" +echo " OpenClaw dir: $OPENCLAW_DIR" +echo "" + +# --- 1. Clone LogLife if not present --- +if [ -d "$LOGLIFE_DIR/plugin" ]; then + echo "[1/5] LogLife repo already exists at $LOGLIFE_DIR — pulling latest..." + git -C "$LOGLIFE_DIR" pull --ff-only 2>/dev/null || echo " (pull skipped — may have local changes)" +else + echo "[1/5] Cloning LogLife..." + git clone https://github.com/jmoraispk/loglife.git "$LOGLIFE_DIR" +fi + +# --- 2. Install the plugin --- +echo "[2/5] Installing LogLife plugin (--link)..." +"$OPENCLAW_BIN" plugins install "$LOGLIFE_DIR/plugin" --link + +# --- 3. Generate API key if not already set --- +EXISTING_KEY=$(grep -o '"apiKey"[[:space:]]*:[[:space:]]*"[^"]*"' ~/.openclaw/openclaw.json 2>/dev/null \ + | head -1 | sed 's/.*"apiKey"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + +if [ -n "$EXISTING_KEY" ] && [ "$EXISTING_KEY" != "" ]; then + echo "[3/5] API key already configured — keeping existing key." + API_KEY="$EXISTING_KEY" +else + echo "[3/5] Generating new API key..." + API_KEY=$(openssl rand -hex 32) + "$OPENCLAW_BIN" config set plugins.entries.loglife.config.apiKey "$API_KEY" +fi + +# --- 4. Restart the gateway --- +echo "[4/5] Restarting gateway..." +"$OPENCLAW_BIN" gateway restart 2>/dev/null || "$OPENCLAW_BIN" gateway start 2>/dev/null || true +sleep 5 + +# --- 5. Health check --- +echo "[5/5] Running health check..." +SESSIONS_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + "http://localhost:18789/loglife/sessions?phone=healthcheck" 2>/dev/null || echo "000") + +VERIFY_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + -X POST -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"phone":"0","code":"000000"}' \ + "http://localhost:18789/loglife/verify/check" 2>/dev/null || echo "000") + +if [ "$SESSIONS_STATUS" = "404" ] || [ "$SESSIONS_STATUS" = "200" ]; then + echo " Sessions endpoint: OK ($SESSIONS_STATUS)" +else + echo " Sessions endpoint: FAILED ($SESSIONS_STATUS)" + echo " The gateway may still be starting. Wait a moment and try:" + echo " curl -H 'Authorization: Bearer YOUR_KEY' http://localhost:18789/loglife/sessions?phone=test" +fi + +if [ "$VERIFY_STATUS" = "200" ]; then + echo " Verify endpoint: OK ($VERIFY_STATUS)" +else + echo " Verify endpoint: FAILED ($VERIFY_STATUS)" +fi + +echo "" +echo "=== Setup complete ===" +echo "" +echo "Your API key:" +echo " $API_KEY" +echo "" +echo "Next steps:" +echo " 1. Set up Caddy reverse proxy (see docs: https://docs.loglife.co/networking)" +echo " 2. Add to Vercel environment variables:" +echo " OPENCLAW_API_URL = https://api.yourdomain.com" +echo " OPENCLAW_API_KEY = $API_KEY" +echo " 3. Add to GitHub Actions secrets (for CI/CD):" +echo " SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY, LOGLIFE_API_KEY" +echo "" 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"], + }, +}); diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index 475abd5e..5ba3b1d8 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -23,6 +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 whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; + const whatsAppConnected = !!whatsappPhone; React.useEffect(() => { if (user) { @@ -118,6 +120,22 @@ export default function AccountPage() { } }; + const handleWhatsAppDisconnect = async () => { + try { + const { whatsappPhone, ...rest } = (user!.unsafeMetadata ?? {}) as Record; + await user!.update({ unsafeMetadata: rest }); + } catch { + alert("Failed to disconnect WhatsApp. Please try again."); + } + }; + + 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 ); @@ -311,41 +329,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 +
- ) : ( -

No connected accounts

+ ))} + + {/* WhatsApp Connection */} +
+
+ + + +
+ WhatsApp + {whatsAppConnected && ( + {maskPhone(whatsappPhone)} + )} +
+
+ {whatsAppConnected ? ( +
+ + Verified + + +
+ ) : ( + + Verify on Dashboard + + )} +
+ + {user.externalAccounts.length === 0 && !whatsAppConnected && ( +

No connected accounts yet

)}
diff --git a/website/app/api/sessions/route.ts b/website/app/api/sessions/route.ts new file mode 100644 index 00000000..8dbbd300 --- /dev/null +++ b/website/app/api/sessions/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } 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 GET(req: NextRequest) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + 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"); + const phone = req.nextUrl.searchParams.get("phone"); + + 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}`, { + 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 reach OpenClaw server" }, { status: 502 }); + } +} 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/components/Sidebar.tsx b/website/app/components/Sidebar.tsx index 7c72d444..512db17e 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 = [ @@ -136,7 +136,7 @@ export default function Sidebar() { {/* Top Navigation Bar - visible below lg */}