From 7ae7158127576a6bb4edc5aa170aad756322c435 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 31 Dec 2025 21:58:30 +0800 Subject: [PATCH] fix: improve namespace-based multi-user --- cli/src/index.ts | 10 ++++++++++ docs/guide/faq.md | 4 ++++ docs/guide/namespace.md | 34 ++++++++++++++++++++++++++++++++ server/src/store/index.ts | 12 +++++++++--- server/src/web/cliApiToken.ts | 37 +++++++++++++++++++++++++++++++---- 5 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 docs/guide/namespace.md diff --git a/cli/src/index.ts b/cli/src/index.ts index 4bfd5ab8..aabd6971 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -438,6 +438,8 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} const messageLower = message.toLowerCase(); const axiosCode = (error as any)?.code; const httpStatus = (error as any)?.response?.status; + const responseError = (error as any)?.response?.data?.error; + const responseErrorText = typeof responseError === 'string' ? responseError : ''; if (axiosCode === 'ECONNREFUSED' || axiosCode === 'ETIMEDOUT' || axiosCode === 'ENOTFOUND' || messageLower.includes('econnrefused') || messageLower.includes('etimedout') || @@ -446,6 +448,14 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} console.error(chalk.yellow('Unable to connect to HAPI server')); console.error(chalk.gray(` Server URL: ${configuration.serverUrl}`)); console.error(chalk.gray(' Please check your network connection or server status')); + } else if (httpStatus === 403 && responseErrorText === 'Machine access denied') { + console.error(chalk.red('Machine access denied.')); + console.error(chalk.gray(' This machineId is already registered under a different namespace.')); + console.error(chalk.gray(' Fix: run `hapi auth logout`, or set a separate HAPI_HOME per namespace.')); + } else if (httpStatus === 403 && responseErrorText === 'Session access denied') { + console.error(chalk.red('Session access denied.')); + console.error(chalk.gray(' This session belongs to a different namespace.')); + console.error(chalk.gray(' Use the matching CLI_API_TOKEN or switch namespaces.')); } else if (httpStatus === 401 || httpStatus === 403 || messageLower.includes('unauthorized') || messageLower.includes('forbidden')) { console.error(chalk.red('Authentication error:'), message); diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 5c867d1f..0f0a3161 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -44,6 +44,10 @@ The `CLI_API_TOKEN` is a shared secret that authenticates: It's auto-generated on first server start and saved to `~/.hapi/settings.json`. +### Do you support multiple accounts? + +Yes. We support lightweight multi-account access via namespaces for shared team servers. See [Namespace (Advanced)](/guide/namespace). + ### Can I use HAPI without Telegram? Yes. Telegram is optional. You can use the web app directly in any browser or install it as a PWA. diff --git a/docs/guide/namespace.md b/docs/guide/namespace.md new file mode 100644 index 00000000..902d1c8f --- /dev/null +++ b/docs/guide/namespace.md @@ -0,0 +1,34 @@ +# Namespace (Advanced) + +Namespaces are intended for small teams sharing a single public HAPI server. Each team member uses a different namespace to isolate their sessions and machines without running separate servers. + +This is not a default setup path for most users. + +## How it works + +- The server uses a single base `CLI_API_TOKEN`. +- Clients append `:` to the token for isolation. + +## Setup + +1. On the server, configure only the base token: + +``` +CLI_API_TOKEN="your-base-token" +``` + +2. For each user, append a namespace in the client token: + +``` +CLI_API_TOKEN="your-base-token:alice" +``` + +3. Web login and Telegram binding should use the same `base:namespace` token. + +## Limitations and gotchas + +- Server-side `CLI_API_TOKEN` must not include `:`. If it does, the server will strip the suffix and log a warning. +- Namespaces are isolated: sessions, machines, and users are not visible across namespaces. +- One machine ID cannot be reused across namespaces. + - To run multiple namespaces on one machine, use a separate `HAPI_HOME` per namespace, or clear the machine ID with `hapi auth logout` before switching. +- Remote spawn is namespace-scoped. If you need remote spawning for multiple namespaces on the same machine, run a separate daemon per namespace (use separate `HAPI_HOME`). diff --git a/server/src/store/index.ts b/server/src/store/index.ts index fd44c272..a18f0737 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -211,6 +211,7 @@ export class Store { } private initSchema(): void { + // Step 1: Create tables and indexes that don't depend on new columns this.db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -230,7 +231,6 @@ export class Store { seq INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag); - CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace); CREATE TABLE IF NOT EXISTS machines ( id TEXT PRIMARY KEY, @@ -245,7 +245,6 @@ export class Store { active_at INTEGER, seq INTEGER DEFAULT 0 ); - CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, @@ -268,9 +267,9 @@ export class Store { UNIQUE(platform, platform_user_id) ); CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform); - CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace); `) + // Step 2: Migrate existing tables (add missing columns) const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> const sessionColumnNames = new Set(sessionColumns.map((c) => c.name)) @@ -295,6 +294,13 @@ export class Store { if (!userColumnNames.has('namespace')) { this.db.exec("ALTER TABLE users ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'") } + + // Step 3: Create indexes that depend on namespace column (after migration) + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace); + CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace); + CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace); + `) } getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): StoredSession { diff --git a/server/src/web/cliApiToken.ts b/server/src/web/cliApiToken.ts index 563fbc0d..5cebc822 100644 --- a/server/src/web/cliApiToken.ts +++ b/server/src/web/cliApiToken.ts @@ -9,6 +9,7 @@ import { existsSync } from 'node:fs' import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { randomBytes } from 'node:crypto' import { dirname, join } from 'node:path' +import { parseAccessToken } from '../utils/accessToken' export interface Settings { machineId?: string @@ -53,6 +54,28 @@ function isWeakToken(token: string): boolean { return weakPatterns.some(p => p.test(token)) } +type CliApiTokenSource = 'env' | 'file' + +function normalizeCliApiToken(rawToken: string, source: CliApiTokenSource): { token: string; didStrip: boolean } { + const parsed = parseAccessToken(rawToken) + if (!parsed) { + if (rawToken.includes(':')) { + console.warn(`[WARN] CLI_API_TOKEN from ${source} contains ":" but is not a valid token. Server expects a base token without namespace.`) + } + return { token: rawToken, didStrip: false } + } + + if (!rawToken.includes(':')) { + return { token: rawToken, didStrip: false } + } + + console.warn( + `[WARN] CLI_API_TOKEN from ${source} includes namespace suffix "${parsed.namespace}". ` + + 'Server expects the base token only; stripping the suffix.' + ) + return { token: parsed.baseToken, didStrip: true } +} + /** * Read settings from file, preserving all existing fields. * Returns null if file exists but cannot be parsed (to avoid data loss). @@ -99,18 +122,19 @@ export async function getOrCreateCliApiToken(dataDir: string): Promise