Files
ocp/docs/superpowers/plans/2026-04-10-lan-mode.md
T
1c29f4867f chore(privacy): scrub personal identifiers from public repo (#43)
Removes all occurrences of maintainer's real name and handle from
tracked files in this PUBLIC repository. Replaces with generic terms
like "project maintainer" / "the maintainer".

Files scrubbed:
- CLAUDE.md (2 instances)
- ALIGNMENT.md (Auditor field)
- docs/adr/0002, 0003, 0004 (Authors/Deciders fields + body text)
- memory/constitution.md (1 instance)
- docs/superpowers/plans/2026-04-10-lan-mode.md (Users/taodeng paths → $HOME)

Also:
- Replaces specific machine hostnames (Taos-Mac-mini, Taos-MacBook-Pro)
  with role-based names (home-mac, travel-macbook) in ADR 0001 (if
  committed; currently untracked).
- Replaces /Users/taodeng/ paths with $HOME/ in old plan doc.

NOTE: git history still contains the personal info. See postmortem
(issue TBD) for whether to rewrite history via git-filter-repo.

Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:01:44 +10:00

1179 lines
37 KiB
Markdown

# OCP LAN Mode — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable OCP to serve Claude API over LAN with flexible auth (none/shared-key/multi-key) and per-key usage tracking with a web dashboard.
**Architecture:** Add LAN bind support (`0.0.0.0`), a key management layer with 3 auth modes, a SQLite-backed usage tracker, and an embedded single-file HTML dashboard. All changes in `server.mjs` + new `keys.mjs` module + CLI extensions in `ocp`.
**Tech Stack:** Node.js built-in `node:crypto`, `better-sqlite3` (single native dependency) for persistent usage tracking, inline HTML/CSS/JS for dashboard.
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `server.mjs` | Modify | Add LAN bind, auth middleware, usage recording hooks, dashboard/key API endpoints |
| `keys.mjs` | Create | Key management + SQLite usage store (CRUD keys, record usage, query stats) |
| `dashboard.html` | Create | Single-file embedded web dashboard (HTML+CSS+JS, served inline) |
| `ocp` | Modify | Add `ocp keys`, `ocp lan`, `ocp usage --by-key` CLI commands |
| `setup.mjs` | Modify | Add LAN mode config prompts during setup |
| `package.json` | Modify | Add `better-sqlite3` dependency, bump version to 3.4.0 |
---
### Task 1: Add `better-sqlite3` dependency and version bump
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Install better-sqlite3**
```bash
cd $HOME/.openclaw/projects/claude-proxy
npm install better-sqlite3
```
- [ ] **Step 2: Bump version to 3.4.0**
In `package.json`, change:
```json
"version": "3.4.0",
```
- [ ] **Step 3: Commit**
```bash
git add package.json package-lock.json
git commit -m "chore: add better-sqlite3 dependency, bump to v3.4.0"
```
---
### Task 2: Create `keys.mjs` — Key management + usage store
**Files:**
- Create: `keys.mjs`
This module handles:
- SQLite database initialization (`~/.ocp/ocp.db`)
- API key CRUD (create, list, revoke)
- Usage recording (per-request: key, model, tokens, elapsed)
- Usage queries (by key, by time range, aggregated)
- [ ] **Step 1: Create keys.mjs with database init and key CRUD**
```javascript
// keys.mjs — API key management and usage tracking for OCP LAN mode
import Database from "better-sqlite3";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
const OCP_DIR = join(homedir(), ".ocp");
mkdirSync(OCP_DIR, { recursive: true });
const DB_PATH = join(OCP_DIR, "ocp.db");
let db;
export function getDb() {
if (!db) {
db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initSchema();
}
return db;
}
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_id INTEGER,
key_name TEXT NOT NULL DEFAULT 'anonymous',
model TEXT NOT NULL,
prompt_chars INTEGER NOT NULL DEFAULT 0,
response_chars INTEGER NOT NULL DEFAULT 0,
elapsed_ms INTEGER NOT NULL DEFAULT 0,
success INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (key_id) REFERENCES api_keys(id)
);
CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at);
CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id);
`);
}
// ── Key CRUD ──
export function createKey(name) {
const key = "ocp_" + randomBytes(24).toString("base64url");
const d = getDb();
const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)");
const result = stmt.run(key, name);
return { id: result.lastInsertRowid, key, name };
}
export function listKeys() {
const d = getDb();
return d.prepare(
"SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC"
).all().map(k => ({
...k,
keyPreview: k.key.slice(0, 8) + "..." + k.key.slice(-4),
}));
}
export function revokeKey(idOrName) {
const d = getDb();
const stmt = d.prepare(
"UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0"
);
return stmt.run(idOrName, idOrName).changes > 0;
}
export function validateKey(key) {
const d = getDb();
const row = d.prepare(
"SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0"
).get(key);
return row || null;
}
// ── Usage recording ──
export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) {
const d = getDb();
d.prepare(`
INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0);
}
// ── Usage queries ──
export function getUsageByKey({ since, until } = {}) {
const d = getDb();
let where = "WHERE 1=1";
const params = [];
if (since) { where += " AND created_at >= ?"; params.push(since); }
if (until) { where += " AND created_at <= ?"; params.push(until); }
return d.prepare(`
SELECT
key_name,
COUNT(*) as requests,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors,
SUM(prompt_chars) as total_prompt_chars,
SUM(response_chars) as total_response_chars,
SUM(elapsed_ms) as total_elapsed_ms,
AVG(elapsed_ms) as avg_elapsed_ms,
MIN(created_at) as first_request,
MAX(created_at) as last_request
FROM usage_log
${where}
GROUP BY key_name
ORDER BY requests DESC
`).all(...params);
}
export function getUsageTimeline({ keyName, hours = 24 } = {}) {
const d = getDb();
const since = new Date(Date.now() - hours * 3600000).toISOString();
let where = "WHERE created_at >= ?";
const params = [since];
if (keyName) { where += " AND key_name = ?"; params.push(keyName); }
return d.prepare(`
SELECT
strftime('%Y-%m-%dT%H:00:00', created_at) as hour,
COUNT(*) as requests,
SUM(prompt_chars) as prompt_chars,
SUM(response_chars) as response_chars,
AVG(elapsed_ms) as avg_elapsed_ms
FROM usage_log
${where}
GROUP BY hour
ORDER BY hour
`).all(...params);
}
export function getRecentUsage(limit = 50) {
const d = getDb();
return d.prepare(`
SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at
FROM usage_log
ORDER BY created_at DESC
LIMIT ?
`).all(limit);
}
export function closeDb() {
if (db) { db.close(); db = null; }
}
```
- [ ] **Step 2: Verify module loads correctly**
```bash
node -e "import('./keys.mjs').then(k => { console.log('OK'); k.closeDb(); })"
```
Expected: `OK` — database file created at `~/.ocp/ocp.db`
- [ ] **Step 3: Commit**
```bash
git add keys.mjs
git commit -m "feat: add keys.mjs — API key management and SQLite usage store"
```
---
### Task 3: Add LAN bind + auth modes to `server.mjs`
**Files:**
- Modify: `server.mjs:28-36` (env vars section)
- Modify: `server.mjs:77-95` (configuration section)
- Modify: `server.mjs:1017-1036` (HTTP server + auth middleware)
- Modify: `server.mjs:1188-1206` (server.listen)
Three auth modes controlled by `CLAUDE_AUTH_MODE`:
- `none` — no auth (LAN trust mode)
- `shared` — single `PROXY_API_KEY` (existing behavior)
- `multi` — per-user keys managed via `keys.mjs`
- [ ] **Step 1: Add new imports and env vars at top of server.mjs**
After the existing imports (line 35), add:
```javascript
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb } from "./keys.mjs";
```
Add new config vars after line 95 (`BREAKER_HALF_OPEN_MAX`):
```javascript
// ── LAN mode configuration ──
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
// AUTH_MODE: "none" (open), "shared" (single PROXY_API_KEY), "multi" (per-user keys from keys.mjs)
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
```
- [ ] **Step 2: Replace auth middleware in HTTP server (lines 1027-1036)**
Replace the existing Bearer token auth block with:
```javascript
// ── Auth middleware ──
// /health is always public. Admin endpoints require admin key in multi mode.
const isPublicEndpoint = req.url === "/health";
let authKeyName = "local";
let authKeyId = null;
if (!isPublicEndpoint) {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (AUTH_MODE === "shared") {
// Single shared key mode (backwards compatible)
if (PROXY_API_KEY) {
const tokenBuf = Buffer.from(token);
const keyBuf = Buffer.from(PROXY_API_KEY);
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
}
authKeyName = "shared";
}
} else if (AUTH_MODE === "multi") {
// Multi-key mode: validate against keys.mjs database
if (!token) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: Bearer token required", type: "auth_error" } });
}
// Check if it's the admin key
if (ADMIN_KEY && token === ADMIN_KEY) {
authKeyName = "admin";
} else {
const keyInfo = validateKey(token);
if (!keyInfo) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or revoked API key", type: "auth_error" } });
}
authKeyName = keyInfo.name;
authKeyId = keyInfo.id;
}
}
// AUTH_MODE === "none": no auth check, allow all
}
// Attach auth info to request for downstream use
req._authKeyName = authKeyName;
req._authKeyId = authKeyId;
```
- [ ] **Step 3: Update CORS to allow LAN origins**
Replace the CORS block (lines 1019-1025) with:
```javascript
// Dynamic CORS: allow localhost and LAN origins
const origin = req.headers["origin"] || "";
const isAllowedOrigin = /^https?:\/\/(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(:\d+)?$/.test(origin);
res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PATCH");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
```
- [ ] **Step 4: Update server.listen to use BIND_ADDRESS**
Replace line 1189:
```javascript
server.listen(PORT, BIND_ADDRESS, () => {
const bindMsg = BIND_ADDRESS === "0.0.0.0" ? `http://0.0.0.0:${PORT} (LAN mode)` : `http://127.0.0.1:${PORT}`;
console.log(`openclaw-claude-proxy v${VERSION} listening on ${bindMsg}`);
```
Add auth mode to startup log (after line 1200):
```javascript
console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`);
console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`);
```
- [ ] **Step 5: Add usage recording to request handlers**
In `handleChatCompletions` (around line 1001-1014), after successful response and in catch block, add usage recording. Wrap the try/catch:
```javascript
try {
const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content);
// Record usage
recordUsage({
keyId: req._authKeyId, keyName: req._authKeyName, model,
promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0),
responseChars: content.length, elapsedMs: Date.now() - Date.now(), success: true,
});
} catch (err) {
recordUsage({
keyId: req._authKeyId, keyName: req._authKeyName, model,
promptChars: 0, responseChars: 0, elapsedMs: 0, success: false,
});
// ... existing error handling
}
```
Similarly for streaming in `callClaudeStreaming`, record usage in the `proc.on("close")` handler by passing `req._authKeyName` and `req._authKeyId` through.
- [ ] **Step 6: Add closeDb to graceful shutdown**
In `gracefulShutdown()` function, after clearing intervals (around line 1150), add:
```javascript
closeDb();
```
- [ ] **Step 7: Commit**
```bash
git add server.mjs
git commit -m "feat: add LAN bind, 3-mode auth, and usage recording to server"
```
---
### Task 4: Add key management + dashboard API endpoints to `server.mjs`
**Files:**
- Modify: `server.mjs` (add routes before the 404 handler)
- [ ] **Step 1: Add key management API endpoints**
Before the 404 handler (line 1132), add:
```javascript
// ── Key management API (admin-only in multi mode) ──
// Admin gate: in multi mode, key management requires admin key
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || BIND_ADDRESS === "127.0.0.1";
// POST /api/keys — create a new API key
if (req.url === "/api/keys" && req.method === "POST") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
let body = "";
for await (const chunk of req) body += chunk;
let parsed;
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
const name = parsed.name || `key-${Date.now()}`;
const newKey = createKey(name);
return jsonResponse(res, 201, newKey);
}
// GET /api/keys — list all keys
if (req.url === "/api/keys" && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
return jsonResponse(res, 200, { keys: listKeys() });
}
// DELETE /api/keys/:id — revoke a key
if (req.url?.startsWith("/api/keys/") && req.method === "DELETE") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1]);
const revoked = revokeKey(idOrName);
return jsonResponse(res, 200, { revoked, idOrName });
}
// GET /api/usage — usage stats by key
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
const since = url.searchParams.get("since");
const until = url.searchParams.get("until");
return jsonResponse(res, 200, {
byKey: getUsageByKey({ since, until }),
timeline: getUsageTimeline({ hours: parseInt(url.searchParams.get("hours") || "24", 10) }),
recent: getRecentUsage(parseInt(url.searchParams.get("limit") || "50", 10)),
});
}
```
- [ ] **Step 2: Commit**
```bash
git add server.mjs
git commit -m "feat: add key management and usage query API endpoints"
```
---
### Task 5: Create embedded web dashboard
**Files:**
- Create: `dashboard.html`
- Modify: `server.mjs` (add `/dashboard` route)
The dashboard is a single HTML file served inline. It uses vanilla JS + CSS, no framework, no build step.
- [ ] **Step 1: Create dashboard.html**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OCP Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 1.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #38bdf8; }
h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #94a3b8; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.card { background: #1e293b; border-radius: 8px; padding: 1rem; }
.card .label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
.card .value { font-size: 1.5rem; font-weight: 700; margin-top: 0.25rem; }
.card .sub { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #334155; font-size: 0.85rem; }
th { color: #64748b; font-weight: 600; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; }
.tag-ok { background: #065f46; color: #6ee7b7; }
.tag-err { background: #7f1d1d; color: #fca5a5; }
.btn { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.btn:hover { background: #1d4ed8; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn-danger { background: #dc2626; }
.btn-danger:hover { background: #b91c1c; }
input { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 0.85rem; }
.flex { display: flex; gap: 0.5rem; align-items: center; }
.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 0.8rem; }
.bar-bg { background: #334155; border-radius: 4px; height: 8px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
.bar-blue { background: #3b82f6; }
.bar-amber { background: #f59e0b; }
.bar-red { background: #ef4444; }
#refresh-indicator { font-size: 0.75rem; color: #475569; }
.section { margin-bottom: 2rem; }
</style>
</head>
<body>
<div class="flex" style="justify-content: space-between; margin-bottom: 1.5rem;">
<h1>OCP Dashboard</h1>
<div class="flex">
<span id="refresh-indicator"></span>
<button class="btn btn-sm" onclick="refreshAll()">Refresh</button>
</div>
</div>
<!-- Status cards -->
<div class="grid" id="status-cards"></div>
<!-- Plan usage -->
<div class="section">
<h2>Plan Usage</h2>
<div class="grid" id="plan-cards"></div>
</div>
<!-- Usage by Key -->
<div class="section">
<h2>Usage by Key</h2>
<table id="key-usage-table">
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- Key Management (admin only) -->
<div class="section" id="key-mgmt-section" style="display:none">
<h2>API Keys</h2>
<div class="flex" style="margin-bottom: 0.75rem;">
<input type="text" id="new-key-name" placeholder="Key name (e.g. wife-laptop)">
<button class="btn btn-sm" onclick="addKey()">Create Key</button>
</div>
<table id="keys-table">
<thead><tr><th>Name</th><th>Key</th><th>Created</th><th>Status</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- Recent Requests -->
<div class="section">
<h2>Recent Requests</h2>
<table id="recent-table">
<thead><tr><th>Time</th><th>Key</th><th>Model</th><th>Prompt</th><th>Response</th><th>Time</th><th>Status</th></tr></thead>
<tbody></tbody>
</table>
</div>
<script>
const BASE = window.location.origin;
const headers = {};
// If a token is stored, use it
const storedToken = localStorage.getItem("ocp_token");
if (storedToken) headers["Authorization"] = `Bearer ${storedToken}`;
async function api(path) {
const resp = await fetch(BASE + path, { headers });
if (resp.status === 401 || resp.status === 403) {
const token = prompt("Enter your OCP admin key:");
if (token) {
localStorage.setItem("ocp_token", token);
headers["Authorization"] = `Bearer ${token}`;
return api(path);
}
}
return resp.json();
}
async function apiPost(path, body) {
const resp = await fetch(BASE + path, {
method: "POST", headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return resp.json();
}
async function apiDelete(path) {
const resp = await fetch(BASE + path, { method: "DELETE", headers });
return resp.json();
}
function fmtTime(ms) {
if (!ms) return "-";
return ms > 1000 ? (ms/1000).toFixed(1) + "s" : Math.round(ms) + "ms";
}
function fmtChars(n) {
if (!n) return "-";
return n > 1000 ? (n/1000).toFixed(0) + "K" : n;
}
function barColor(pct) {
if (pct >= 80) return "bar-red";
if (pct >= 50) return "bar-amber";
return "bar-blue";
}
async function refreshStatus() {
const data = await api("/status");
const p = data.proxy || {};
const r = data.requests || {};
document.getElementById("status-cards").innerHTML = `
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
`;
// Plan usage
const plan = data.plan;
if (plan && !plan.error) {
const s = plan.currentSession || {};
const w = plan.weeklyLimits?.allModels || {};
const sPct = Math.round((s.utilization || 0) * 100);
const wPct = Math.round((w.utilization || 0) * 100);
document.getElementById("plan-cards").innerHTML = `
<div class="card">
<div class="label">Session (5h)</div>
<div class="value">${s.percent || '?'}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
</div>
<div class="card">
<div class="label">Weekly (7d)</div>
<div class="value">${w.percent || '?'}</div>
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
</div>
`;
}
}
async function refreshUsage() {
try {
const data = await api("/api/usage");
// By key
const tbody = document.querySelector("#key-usage-table tbody");
tbody.innerHTML = (data.byKey || []).map(k => `
<tr>
<td>${k.key_name}</td>
<td>${k.requests}</td>
<td>${k.successes}</td>
<td>${k.errors}</td>
<td>${fmtTime(k.avg_elapsed_ms)}</td>
<td class="mono">${k.last_request || '-'}</td>
</tr>
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
// Recent
const rtbody = document.querySelector("#recent-table tbody");
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
<tr>
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
<td>${r.key_name}</td>
<td>${r.model}</td>
<td>${fmtChars(r.prompt_chars)}</td>
<td>${fmtChars(r.response_chars)}</td>
<td>${fmtTime(r.elapsed_ms)}</td>
<td><span class="tag ${r.success ? 'tag-ok' : 'tag-err'}">${r.success ? 'ok' : 'err'}</span></td>
</tr>
`).join("") || '<tr><td colspan="7" style="color:#475569">No requests yet</td></tr>';
} catch(e) {
console.warn("Usage API not available:", e);
}
}
async function refreshKeys() {
try {
const data = await api("/api/keys");
document.getElementById("key-mgmt-section").style.display = "";
const tbody = document.querySelector("#keys-table tbody");
tbody.innerHTML = (data.keys || []).map(k => `
<tr>
<td>${k.name}</td>
<td class="mono">${k.keyPreview}</td>
<td class="mono">${k.created_at}</td>
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
</tr>
`).join("");
} catch(e) {
// Key management not available (not admin or not multi mode)
}
}
async function addKey() {
const name = document.getElementById("new-key-name").value.trim();
if (!name) return alert("Enter a key name");
const result = await apiPost("/api/keys", { name });
if (result.key) {
alert("New API Key (copy it now — you won't see it again):\n\n" + result.key);
document.getElementById("new-key-name").value = "";
refreshKeys();
}
}
async function revokeKeyUI(name) {
if (!confirm(`Revoke key "${name}"?`)) return;
await apiDelete(`/api/keys/${encodeURIComponent(name)}`);
refreshKeys();
}
async function refreshAll() {
document.getElementById("refresh-indicator").textContent = "Refreshing...";
await Promise.all([refreshStatus(), refreshUsage(), refreshKeys()]);
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
}
refreshAll();
setInterval(refreshAll, 30000);
</script>
</body>
</html>
```
- [ ] **Step 2: Add dashboard route to server.mjs**
Before the 404 handler, add:
```javascript
// GET /dashboard — web dashboard
if (req.url === "/dashboard" && req.method === "GET") {
try {
const html = readFileSync(join(__dirname, "dashboard.html"), "utf8");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
} catch (err) {
return jsonResponse(res, 500, { error: "Dashboard file not found" });
}
return;
}
```
- [ ] **Step 3: Update 404 message to include new endpoints**
```javascript
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions, GET /dashboard, GET|POST|DELETE /api/keys, GET /api/usage" });
```
- [ ] **Step 4: Commit**
```bash
git add dashboard.html server.mjs
git commit -m "feat: add embedded web dashboard with real-time usage display"
```
---
### Task 6: Extend `ocp` CLI with key management and LAN commands
**Files:**
- Modify: `ocp` (bash CLI)
- [ ] **Step 1: Add `ocp keys` command**
After the `cmd_clear` section (around line 214), add:
```bash
# ── keys ────────────────────────────────────────────────────────────────
cmd_keys_help() {
cat <<'EOF'
ocp keys — Manage API keys (multi-key auth mode)
Usage:
ocp keys List all keys
ocp keys add <name> Create a new key
ocp keys revoke <name|id> Revoke a key
Examples:
ocp keys add wife-laptop
ocp keys add son-ipad
ocp keys revoke wife-laptop
EOF
}
cmd_keys() {
case "${1:-}" in
add)
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add <name>"; return 1; fi
local result
result=$(curl -sf --max-time 5 -X POST "$PROXY/api/keys" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; }
echo "$result" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
if 'key' in d:
print(f'✓ Key created for \"{d[\"name\"]}\"')
print(f'')
print(f' API Key: {d[\"key\"]}')
print(f'')
print(f' Copy this key now — you won\\'t see it again.')
print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}')
else:
print(f'✗ {d.get(\"error\", \"Unknown error\")}')
"
;;
revoke)
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke <name|id>"; return 1; fi
curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
if d.get('revoked'):
print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.')
else:
print(f'✗ Key not found or already revoked.')
"
;;
""|--help|-h)
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then cmd_keys_help; return; fi
# List keys
curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
keys = d.get('keys', [])
if not keys:
print('No API keys configured.')
print('Create one: ocp keys add <name>')
else:
print('API Keys')
print('─────────────────────────────────────────────────')
for k in keys:
status = '✗ revoked' if k['revoked'] else '✓ active'
print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}')
" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; }
;;
*)
echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;;
esac
}
```
- [ ] **Step 2: Add `ocp lan` command**
```bash
# ── lan ─────────────────────────────────────────────────────────────────
cmd_lan_help() {
cat <<'EOF'
ocp lan — Quick LAN mode setup guide
Shows current network configuration and connection instructions
for other devices on the same network.
Usage: ocp lan
EOF
}
cmd_lan() {
local ip
ip=$(ipconfig getifaddr en0 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')
local port
port=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; h=json.loads(sys.stdin.read()); print(h.get('config',{}).get('port', '3456'))" 2>/dev/null || echo "3456")
echo "OCP LAN Setup"
echo "─────────────────────────────────────"
echo ""
echo " Your IP: $ip"
echo " Port: $port"
echo ""
echo " For IDE users, set:"
echo " OPENAI_BASE_URL=http://$ip:$port/v1"
echo " OPENAI_API_KEY=<your-key> (if auth enabled)"
echo ""
echo " Dashboard: http://$ip:$port/dashboard"
echo ""
# Check if currently bound to LAN
if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then
echo " Status: ✓ LAN-accessible"
else
echo " Status: ✗ Not LAN-accessible (bound to localhost only)"
echo ""
echo " To enable LAN mode, set env var and restart:"
echo " CLAUDE_BIND=0.0.0.0"
echo " ocp restart"
fi
}
```
- [ ] **Step 3: Add `ocp usage --by-key` option**
Modify `cmd_usage()` to accept `--by-key`:
After the existing `cmd_usage()` function, add a check:
```bash
# In the cmd_usage function, add at the top:
cmd_usage() {
if [[ "${1:-}" == "--by-key" ]]; then
local data
data=$(curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; }
echo "$data" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())
by_key = d.get('byKey', [])
if not by_key:
print('No usage data yet.')
else:
print('Usage by Key')
print('─────────────────────────────────────────────────────────────────')
hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}'
print(hdr)
print(' ' + '─' * (len(hdr) - 2))
for k in by_key:
avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-'
print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}')
"
return
fi
# ... existing cmd_usage code below ...
```
- [ ] **Step 4: Update dispatch case and help**
In the `case` statement at the bottom, add:
```bash
keys) cmd_keys "${1:-}" "${2:-}" ;;
lan) cmd_lan ;;
```
Update `cmd_help()`:
```bash
keys Manage API keys (add/list/revoke)
lan LAN mode setup guide
usage Plan usage limits (--by-key for per-key stats)
```
- [ ] **Step 5: Commit**
```bash
git add ocp
git commit -m "feat: add ocp keys, ocp lan, and ocp usage --by-key CLI commands"
```
---
### Task 7: Update setup.mjs with LAN mode prompts
**Files:**
- Modify: `setup.mjs`
- [ ] **Step 1: Add LAN mode env vars to launchd plist and systemd service**
In `setup.mjs`, in the plist XML template (around line 351), add environment variables for LAN mode if the user chose it. Add these optional env entries to the `EnvironmentVariables` dict:
```xml
<key>CLAUDE_BIND</key>
<string>${BIND_ADDRESS}</string>
<key>CLAUDE_AUTH_MODE</key>
<string>${AUTH_MODE_CONFIG}</string>
```
Similarly update the systemd service template to include:
```
Environment=CLAUDE_BIND=${BIND_ADDRESS}
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
```
Add at the top of setup.mjs, after existing `opt()` calls:
```javascript
const BIND_ADDRESS = opt("bind", "127.0.0.1");
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
```
- [ ] **Step 2: Commit**
```bash
git add setup.mjs
git commit -m "feat: add LAN mode config options to setup.mjs"
```
---
### Task 8: Update README with LAN mode documentation
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Add LAN Mode section to README**
Add a new section after the existing "Quick Start" section:
```markdown
## LAN Mode — Share with Family
OCP can serve your entire household from a single machine.
### Enable LAN Access
```bash
# Set bind address and restart
export CLAUDE_BIND=0.0.0.0
ocp restart
```
Or permanently via setup:
```bash
node setup.mjs --bind 0.0.0.0 --auth-mode multi
```
### Auth Modes
| Mode | Env | Description |
|------|-----|-------------|
| `none` | `CLAUDE_AUTH_MODE=none` | Open access (trusted network) |
| `shared` | `CLAUDE_AUTH_MODE=shared` | Single key via `PROXY_API_KEY` |
| `multi` | `CLAUDE_AUTH_MODE=multi` | Per-user keys with usage tracking |
### Multi-Key Setup
```bash
# Create keys for family members
ocp keys add wife-laptop
ocp keys add son-ipad
# List keys
ocp keys
# View per-key usage
ocp usage --by-key
# Revoke a key
ocp keys revoke son-ipad
```
### Family Member Setup
Give your family member these settings for their IDE:
```
OPENAI_BASE_URL=http://<your-ip>:3456/v1
OPENAI_API_KEY=ocp_<their-key>
```
Run `ocp lan` to see your IP and connection guide.
### Web Dashboard
Access the usage dashboard at: `http://<your-ip>:3456/dashboard`
Shows real-time stats: per-key usage, request history, plan utilization, and system health.
```
- [ ] **Step 2: Update version in README**
Change version references from `v3.3.1` to `v3.4.0`.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: add LAN mode documentation and family sharing guide"
```
---
### Task 9: Integration test — end-to-end verification
**Files:**
- No new files — manual verification steps
- [ ] **Step 1: Start OCP in LAN mode with multi-key auth**
```bash
cd $HOME/.openclaw/projects/claude-proxy
CLAUDE_BIND=0.0.0.0 CLAUDE_AUTH_MODE=multi OCP_ADMIN_KEY=test-admin-123 node server.mjs &
sleep 3
```
- [ ] **Step 2: Verify health endpoint (public, no auth)**
```bash
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | head -5
```
Expected: JSON with status "ok", version "3.4.0"
- [ ] **Step 3: Create a test API key**
```bash
curl -s -X POST http://127.0.0.1:3456/api/keys \
-H "Authorization: Bearer test-admin-123" \
-H "Content-Type: application/json" \
-d '{"name": "test-user"}' | python3 -m json.tool
```
Expected: JSON with `id`, `key` (starts with `ocp_`), `name`
- [ ] **Step 4: Test request with key**
```bash
# Save the key from step 3
KEY="<the-ocp-key>"
curl -s http://127.0.0.1:3456/v1/chat/completions \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"model":"haiku","messages":[{"role":"user","content":"say hi"}]}' | python3 -m json.tool | head -10
```
Expected: Chat completion response from Claude
- [ ] **Step 5: Verify usage tracking**
```bash
curl -s http://127.0.0.1:3456/api/usage \
-H "Authorization: Bearer test-admin-123" | python3 -m json.tool
```
Expected: Usage data showing `test-user` with 1 request
- [ ] **Step 6: Test dashboard loads**
```bash
curl -s http://127.0.0.1:3456/dashboard | head -5
```
Expected: HTML starting with `<!DOCTYPE html>`
- [ ] **Step 7: Test auth rejection (no key in multi mode)**
```bash
curl -s http://127.0.0.1:3456/v1/models
```
Expected: 401 error with "Bearer token required"
- [ ] **Step 8: Stop test server and clean up**
```bash
pkill -f "server.mjs" || true
```
- [ ] **Step 9: Commit integration test results as passing**
```bash
git add -A
git commit -m "test: verify LAN mode, multi-key auth, usage tracking, and dashboard"
```
---
## Summary
| Task | Description | Est. Lines |
|------|-------------|-----------|
| 1 | Add dependency + version bump | ~5 |
| 2 | `keys.mjs` — key CRUD + usage store | ~180 |
| 3 | LAN bind + auth modes in server | ~80 |
| 4 | Key mgmt + usage API endpoints | ~50 |
| 5 | Web dashboard (HTML+JS) | ~200 |
| 6 | CLI extensions (`ocp keys/lan`) | ~150 |
| 7 | Setup.mjs LAN config | ~20 |
| 8 | README documentation | ~60 |
| 9 | Integration testing | ~0 |
| **Total** | | **~745 lines** |